NewsBlur/clients/ios/Classes/DetailViewController.swift
David Sinclair 75e4fb7e85 #1720 (Grid view)
- Fixed list view stories incorrectly marked as read.
- Fixed grid view wrong dates in story header.
- Fixed grid view heading not matching story.
- Fixed grid view showing the wrong story.
- Fixed grid view duplicate stories.
- Tapping grid view story heading now scrolls to show that story’s card.
- Now always shows the Sites button in non-left layout, since the collapse fullscreen button doesn’t seem to work.
- Fixed crash with malformed story.
- Fixed loading feeds in different layouts.
- And some other tweaks.
2023-08-11 11:40:13 -06:00

594 lines
22 KiB
Swift

//
// DetailViewController.swift
// NewsBlur
//
// Created by David Sinclair on 2020-08-27.
// Copyright © 2020 NewsBlur. All rights reserved.
//
import UIKit
/// Manages the detail column of the split view, with the feed detail and/or the story pages.
class DetailViewController: BaseViewController {
/// Returns the shared app delegate.
var appDelegate: NewsBlurAppDelegate {
return NewsBlurAppDelegate.shared()
}
/// Preference keys.
enum Key {
/// Style of the feed detail list layout.
static let style = "story_titles_style"
/// Behavior of the split controller.
static let behavior = "split_behavior"
/// Position of the divider between the views when in horizontal orientation. Only used for `.top` and `.bottom` layouts.
static let horizontalPosition = "story_titles_divider_horizontal"
/// Position of the divider between the views when in vertical orientation. Only used for `.top` and `.bottom` layouts.
static let verticalPosition = "story_titles_divider_vertical"
}
/// Preference values.
enum LayoutValue {
static let left = "titles_on_left"
static let top = "titles_on_top"
static let bottom = "titles_on_bottom"
static let grid = "titles_in_grid"
}
/// How the feed detail and story pages are laid out.
enum Layout {
/// The feed detail is to the left of the story pages (and managed by the split view, not here).
case left
/// The feed detail is at the top, the story pages at the bottom.
case top
/// The story pages are at the top, the feed detail at the bottom.
case bottom
/// Using a grid view for the story titles and story pages.
case grid
}
/// How the feed detail and story pages are laid out.
var layout: Layout {
get {
switch appDelegate.storiesCollection.activeStoryTitlesPosition {
case LayoutValue.top:
return .top
case LayoutValue.bottom:
return .bottom
case LayoutValue.grid:
return .grid
default:
return .left
}
}
set {
guard newValue != layout, let key = appDelegate.storiesCollection.storyTitlesPositionKey else {
return
}
switch newValue {
case .top:
UserDefaults.standard.set(LayoutValue.top, forKey: key)
case .bottom:
UserDefaults.standard.set(LayoutValue.bottom, forKey: key)
case .grid:
UserDefaults.standard.set(LayoutValue.grid, forKey: key)
default:
UserDefaults.standard.set(LayoutValue.left, forKey: key)
}
updateLayout(reload: true, fetchFeeds: true)
}
}
/// Whether or not the feed detail is on the left; see also the following properties.
@objc var storyTitlesOnLeft: Bool {
return layout == .left
}
/// Whether or not the feed detail is on the top; see also the previous property.
@objc var storyTitlesOnTop: Bool {
return layout == .top
}
/// Whether or not using the grid layout; see also the previous properties.
@objc var storyTitlesInGrid: Bool {
return layout == .grid
}
/// Preference values.
enum StyleValue {
static let standard = "standard"
static let experimental = "experimental"
}
/// Style of the feed detail list layout.
enum Style {
/// The feed detail list uses the legacy table view.
case standard
/// The feed detail list uses the SwiftUI grid view.
case experimental
}
/// Style of the feed detail list layout.
var style: Style {
get {
switch UserDefaults.standard.string(forKey: Key.style) {
case StyleValue.experimental:
return .experimental
default:
return .standard
}
}
set {
guard newValue != style else {
return
}
switch newValue {
case .experimental:
UserDefaults.standard.set(StyleValue.experimental, forKey: Key.style)
default:
UserDefaults.standard.set(StyleValue.standard, forKey: Key.style)
}
updateLayout(reload: true, fetchFeeds: true)
}
}
/// Whether or not using the legacy list for non-grid layout.
@objc var storyTitlesInLegacyTable: Bool {
return layout != .grid && style != .experimental
}
/// Preference values.
enum BehaviorValue {
static let auto = "auto"
static let tile = "tile"
static let displace = "displace"
static let overlay = "overlay"
}
/// How the split controller behaves.
enum Behavior {
/// The split controller figures out the best behavior.
case auto
/// The split controller arranges the views side-by-side.
case tile
/// The split controller pushes the detail view aside.
case displace
/// The split controller puts the left columns over the detail view.
case overlay
}
/// How the split controller behaves.
var behavior: Behavior {
switch UserDefaults.standard.string(forKey: Key.behavior) {
case BehaviorValue.tile:
return .tile
case BehaviorValue.displace:
return .displace
case BehaviorValue.overlay:
return .overlay
default:
return .auto
}
}
/// Returns `true` if the device is an iPhone, otherwise `false`.
@objc var isPhone: Bool {
return UIDevice.current.userInterfaceIdiom == .phone
}
/// Returns `true` if the window is in portrait orientation, otherwise `false`.
@objc var isPortraitOrientation: Bool {
return view.window?.windowScene?.interfaceOrientation.isPortrait ?? false
}
/// Position of the divider between the views.
var dividerPosition: CGFloat {
get {
let key = isPortraitOrientation ? Key.verticalPosition : Key.horizontalPosition
let value = CGFloat(UserDefaults.standard.float(forKey: key))
if value == 0 {
return 200
} else {
return value
}
}
set {
guard newValue != dividerPosition else {
return
}
let key = isPortraitOrientation ? Key.verticalPosition : Key.horizontalPosition
UserDefaults.standard.set(Float(newValue), forKey: key)
}
}
/// Top container view.
@IBOutlet weak var topContainerView: UIView!
/// Bottom container view.
@IBOutlet weak var bottomContainerView: UIView!
/// Draggable divider view.
@IBOutlet weak var dividerView: UIView!
/// Indicator image in the divider view.
@IBOutlet weak var dividerImageView: UIImageView!
/// Top container view top constraint. May need to adjust this for fullscreen on iPhone.
@IBOutlet weak var topContainerTopConstraint: NSLayoutConstraint!
/// Bottom constraint of the divider view.
@IBOutlet weak var dividerViewBottomConstraint: NSLayoutConstraint!
/// The navigation controller managed by the split view controller, that encloses the immediate navigation controller of the detail view when in compact layout.
@objc var parentNavigationController: UINavigationController? {
return navigationController?.parent as? UINavigationController
}
/// The feed detail navigation controller in the supplementary pane, loaded from the storyboard.
var supplementaryFeedDetailNavigationController: UINavigationController?
/// The feed detail view controller in the supplementary pane, loaded from the storyboard.
var supplementaryFeedDetailViewController: FeedDetailViewController?
/// The feed detail view controller, if using `top`, `bottom`, or `grid` layout. `nil` if using `left` layout.
var feedDetailViewController: FeedDetailViewController?
/// Whether or not the grid layout was used the last time checking the view controllers.
var wasGrid = false
/// The horizontal page view controller. [Not currently used; might be used for #1351 (gestures in vertical scrolling).]
// var horizontalPageViewController: HorizontalPageViewController?
/// An instance of the story pages view controller for list layouts.
lazy var listStoryPagesViewController = StoryPagesViewController()
/// A separate instance of the story pages view controller for use in the grid layout.
lazy var gridStoryPagesViewController = StoryPagesViewController()
/// The story pages view controller, that manages the previous, current, and next story view controllers.
@objc var storyPagesViewController: StoryPagesViewController?
/// Returns the currently displayed story view controller, or `nil` if none.
@objc var currentStoryController: StoryDetailViewController? {
return storyPagesViewController?.currentPage
}
/// Prepare the views.
@objc func checkLayout() {
checkViewControllers()
}
/// Updates the layout; call this when the layout is changed in the preferences.
@objc(updateLayoutWithReload:fetchFeeds:) func updateLayout(reload: Bool, fetchFeeds: Bool) {
checkViewControllers()
if fetchFeeds {
appDelegate.feedsViewController.loadOfflineFeeds(false)
}
if layout != .left, let controller = feedDetailViewController {
navigationItem.leftBarButtonItems = [controller.feedsBarButton, controller.settingsBarButton]
} else {
navigationItem.leftBarButtonItems = []
}
if reload {
self.feedDetailViewController?.reload()
}
}
/// Update the theme.
@objc override func updateTheme() {
super.updateTheme()
guard let manager = ThemeManager.shared else {
return
}
manager.update(navigationController)
manager.updateBackground(of: view)
dividerImageView.image = manager.themedImage(UIImage(named: "drag_icon.png"))
view.backgroundColor = navigationController?.navigationBar.barTintColor
navigationController?.navigationBar.barStyle = manager.isDarkTheme ? .black : .default
tidyNavigationController()
}
/// Moves the story pages controller to a Grid layout cell content (automatically removing it from the previous parent).
func prepareStoriesForGridView() {
guard !isPhone, let storyPagesViewController else {
return
}
print("🎈 prepareStoriesForGridView: \(storyPagesViewController.currentPage.activeStory?["story_title"] ?? "none")")
remove(viewController: storyPagesViewController)
storyPagesViewController.updatePage(withActiveStory: appDelegate.storiesCollection.locationOfActiveStory(), updateFeedDetail: false)
adjustForAutoscroll()
storyPagesViewController.currentPage.webView.scrollView.isScrollEnabled = false
}
/// Moves the story pages controller to a Grid layout cell content (automatically removing it from the previous parent).
// @objc func moveStoriesToGridCell(_ cellContent: UIView) {
// #warning("hack disabled for SwiftUI experiment")
//// guard let storyPagesViewController else {
//// return
//// }
////
//// print("🎈 moveStoriesToGridCell: \(storyPagesViewController.currentPage.activeStory["story_title"] ?? "none")")
////
//// add(viewController: storyPagesViewController, to: cellContent, of: appDelegate.feedDetailViewController)
////
//// adjustForAutoscroll()
////
//// storyPagesViewController.currentPage.webView.scrollView.isScrollEnabled = false
// }
/// Moves the story pages controller to the appropriate container in the detail controller (automatically removing it from the previous parent).
@objc func moveStoriesToDetailContainer() {
guard let storyPagesViewController else {
return
}
let isTop = layout == .top
let appropriateSuperview = isTop ? bottomContainerView : topContainerView
if storyPagesViewController.view.superview != appropriateSuperview {
add(viewController: storyPagesViewController, top: !isTop)
adjustForAutoscroll()
storyPagesViewController.currentPage.webView.scrollView.isScrollEnabled = true
}
}
/// Adjusts the container when autoscrolling. Only applies to iPhone.
@objc func adjustForAutoscroll() {
adjustTopConstraint()
updateTheme()
}
override func viewDidLoad() {
super.viewDidLoad()
updateLayout(reload: false, fetchFeeds: true)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
adjustTopConstraint()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
if [.top, .bottom].contains(layout) {
coordinator.animate { context in
self.dividerViewBottomConstraint.constant = self.dividerPosition
}
}
coordinator.animate { context in
self.adjustTopConstraint()
}
}
private func adjustTopConstraint() {
guard let scene = view.window?.windowScene else {
return
}
if !isPhone {
if scene.traitCollection.horizontalSizeClass == .compact {
topContainerTopConstraint.constant = -50
} else {
topContainerTopConstraint.constant = 0
}
} else if let controller = storyPagesViewController, !controller.isNavigationBarHidden {
let navigationHeight = navigationController?.navigationBar.frame.height ?? 0
let adjustment: CGFloat = view.safeAreaInsets.top > 25 ? 5 : 0
topContainerTopConstraint.constant = -(navigationHeight - adjustment)
} else {
topContainerTopConstraint.constant = 0
}
}
private var isDraggingDivider = false
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
let touch = touches.first
guard let point = touch?.location(in: view) else {
return
}
let isInside = dividerView.frame.contains(point)
guard touch?.view == dividerView || isInside || isDraggingDivider else {
return
}
isDraggingDivider = true
let position = view.frame.height - point.y - 6
guard position > 150, position < view.frame.height - 200 else {
return
}
dividerPosition = position
dividerViewBottomConstraint.constant = position
view.setNeedsLayout()
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
isDraggingDivider = false
}
}
private extension DetailViewController {
func checkViewControllers() {
let isTop = layout == .top
if layout != .grid || isPhone {
storyPagesViewController = listStoryPagesViewController
_ = storyPagesViewController?.view
moveStoriesToDetailContainer()
} else {
storyPagesViewController = gridStoryPagesViewController
_ = storyPagesViewController?.view
}
if layout == .left || isPhone {
if feedDetailViewController != nil {
remove(viewController: feedDetailViewController)
feedDetailViewController = nil
supplementaryFeedDetailViewController?.storiesCollection = appDelegate.storiesCollection
appDelegate.feedDetailNavigationController = supplementaryFeedDetailNavigationController
appDelegate.feedDetailViewController = supplementaryFeedDetailViewController
appDelegate.splitViewController.setViewController(supplementaryFeedDetailNavigationController, for: .supplementary)
supplementaryFeedDetailNavigationController = nil
supplementaryFeedDetailViewController = nil
}
if !isPhone {
appDelegate.show(.supplementary, debugInfo: "checkViewControllers")
}
if wasGrid && !isPhone {
DispatchQueue.main.async {
self.appDelegate.loadStoryDetailView()
}
}
dividerViewBottomConstraint.constant = -13
wasGrid = false
} else if layout == .grid {
if feedDetailViewController == nil || !wasGrid {
remove(viewController: feedDetailViewController)
feedDetailViewController = Storyboards.shared.controller(withIdentifier: .feedDetail) as? FeedDetailViewController
feedDetailViewController?.storiesCollection = appDelegate.storiesCollection
add(viewController: feedDetailViewController, top: true)
if appDelegate.feedDetailNavigationController != nil {
supplementaryFeedDetailNavigationController = appDelegate.feedDetailNavigationController
}
supplementaryFeedDetailViewController = appDelegate.feedDetailViewController
appDelegate.feedDetailNavigationController = nil
appDelegate.feedDetailViewController = feedDetailViewController
appDelegate.splitViewController.setViewController(nil, for: .supplementary)
} else {
add(viewController: feedDetailViewController, top: true)
}
dividerViewBottomConstraint.constant = -13
wasGrid = true
} else {
if feedDetailViewController == nil || wasGrid {
remove(viewController: feedDetailViewController)
feedDetailViewController = Storyboards.shared.controller(withIdentifier: .feedDetail) as? FeedDetailViewController
feedDetailViewController?.storiesCollection = appDelegate.storiesCollection
add(viewController: feedDetailViewController, top: isTop)
if appDelegate.feedDetailNavigationController != nil {
supplementaryFeedDetailNavigationController = appDelegate.feedDetailNavigationController
}
supplementaryFeedDetailViewController = appDelegate.feedDetailViewController
appDelegate.feedDetailNavigationController = nil
appDelegate.feedDetailViewController = feedDetailViewController
appDelegate.splitViewController.setViewController(nil, for: .supplementary)
} else {
let appropriateSuperview = isTop ? topContainerView : bottomContainerView
if feedDetailViewController?.view.superview != appropriateSuperview {
add(viewController: feedDetailViewController, top: isTop)
}
}
dividerViewBottomConstraint.constant = dividerPosition
appDelegate.updateSplitBehavior(true)
wasGrid = false
}
appDelegate.feedDetailViewController.changedLayout()
}
func add(viewController: UIViewController?, top: Bool) {
if top {
add(viewController: viewController, to: topContainerView)
} else {
add(viewController: viewController, to: bottomContainerView)
}
}
func add(viewController: UIViewController?, to containerView: UIView, of containerController: UIViewController? = nil) {
guard let viewController else {
return
}
let containerController = containerController ?? self
containerController.addChild(viewController)
containerView.addSubview(viewController.view)
viewController.view.translatesAutoresizingMaskIntoConstraints = false
viewController.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor).isActive = true
viewController.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor).isActive = true
viewController.view.topAnchor.constraint(equalTo: containerView.topAnchor).isActive = true
viewController.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor).isActive = true
viewController.didMove(toParent: containerController)
}
func remove(viewController: UIViewController?) {
guard let viewController else {
return
}
viewController.willMove(toParent: nil)
viewController.removeFromParent()
viewController.view.removeFromSuperview()
}
/// The status bar portion of the navigation controller isn't the right color, due to a white subview bleeding through the visual effect view. This somewhat hacky function will correct that.
func tidyNavigationController() {
guard let visualEffectSubviews = navigationController?.navigationBar.subviews.first?.subviews.first?.subviews, visualEffectSubviews.count == 3, visualEffectSubviews[1].alpha == 1 else {
return
}
navigationController?.navigationBar.subviews.first?.backgroundColor = UINavigationBar.appearance().backgroundColor
}
}