- Experimenting with using SwiftUI for the list and grid views. Working really well!
- Beginnings of a real data model, at last; probably only for caching stories initially.
This commit is contained in:
David Sinclair 2023-01-21 16:20:53 -06:00
parent 8985bf85e4
commit 7b28791603
7 changed files with 541 additions and 7 deletions

View file

@ -257,20 +257,36 @@ class DetailViewController: BaseViewController {
}
/// Moves the story pages controller to a Grid layout cell content (automatically removing it from the previous parent).
@objc func moveStoriesToGridCell(_ cellContent: UIView) {
func prepareStoriesForGridView() {
guard let storyPagesViewController else {
return
}
print("🎈 moveStoriesToGridCell: \(storyPagesViewController.currentPage.activeStory["story_title"] ?? "none")")
print("🎈 prepareStoriesForGridView: \(storyPagesViewController.currentPage.activeStory?["story_title"] ?? "none")")
add(viewController: storyPagesViewController, to: cellContent, of: appDelegate.feedDetailViewController)
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 detail controller (automatically removing it from the previous parent).
@objc func moveStoriesToDetail() {
guard let storyPagesViewController else {

View file

@ -0,0 +1,442 @@
//
// FeedDetailGridView.swift
// NewsBlur
//
// Created by David Sinclair on 2023-01-19.
// Copyright © 2023 NewsBlur. All rights reserved.
//
import SwiftUI
// NOTE: this code is rather untidy, as it is experimental; it'll get cleaned up later.
class Story: Identifiable {
let id = UUID()
let index: Int
// lazy var title: String = {
// return "lazy story #\(index)"
// }()
var dictionary: [String : Any]?
var feedID = ""
var feedName = ""
var title = ""
var content = ""
var dateString = ""
var timestamp = 0
var isSaved = false
var isShared = false
var hash = ""
var author = ""
var dateAndAuthor: String {
let date = Utilities.formatShortDate(fromTimestamp: timestamp) ?? ""
return author.isEmpty ? date : "\(date) · \(author)"
}
var feedColorBar: UIColor?
var feedColorBarTopBorder: UIColor?
var isSelected: Bool {
return index == NewsBlurAppDelegate.shared!.storiesCollection.indexOfActiveStory()
}
init(index: Int) {
self.index = index
}
private func string(for key: String) -> String {
guard let dictionary else {
return ""
}
return dictionary[key] as? String ?? ""
}
func load() {
guard let appDelegate = NewsBlurAppDelegate.shared, let storiesCollection = appDelegate.storiesCollection,
index < storiesCollection.activeFeedStoryLocations.count,
let row = storiesCollection.activeFeedStoryLocations[index] as? Int,
let story = storiesCollection.activeFeedStories[row] as? [String : Any] else {
return
}
dictionary = story
feedID = appDelegate.feedIdWithoutSearchQuery(string(for: "story_feed_id"))
feedName = string(for: "feed_title")
title = (string(for: "story_title") as NSString).decodingHTMLEntities()
content = String(string(for: "story_content").convertHTML().decodingXMLEntities().decodingHTMLEntities().replacingOccurrences(of: "\n", with: " ").prefix(500))
author = string(for: "story_authors").replacingOccurrences(of: "\"", with: "")
dateString = string(for: "short_parsed_date")
timestamp = dictionary?["story_timestamp"] as? Int ?? 0
isSaved = dictionary?["starred"] as? Bool ?? false
isShared = dictionary?["shared"] as? Bool ?? false
hash = string(for: "story_hash")
//TODO: 🚧 might make some of these lazy computed properties
// // feed color bar border
// unsigned int colorBorder = 0;
// NSString *faviconColor = [feed valueForKey:@"favicon_fade"];
//
// if ([faviconColor class] == [NSNull class] || !faviconColor) {
// faviconColor = @"707070";
// }
// NSScanner *scannerBorder = [NSScanner scannerWithString:faviconColor];
// [scannerBorder scanHexInt:&colorBorder];
//
// cell.feedColorBar = UIColorFromFixedRGB(colorBorder);
//
// // feed color bar border
// NSString *faviconFade = [feed valueForKey:@"favicon_color"];
// if ([faviconFade class] == [NSNull class] || !faviconFade) {
// faviconFade = @"505050";
// }
// scannerBorder = [NSScanner scannerWithString:faviconFade];
// [scannerBorder scanHexInt:&colorBorder];
// cell.feedColorBarTopBorder = UIColorFromFixedRGB(colorBorder);
//
// // favicon
// cell.siteFavicon = [appDelegate getFavicon:feedIdStr];
// cell.hasAlpha = NO;
//
// // undread indicator
//
// int score = [NewsBlurAppDelegate computeStoryScore:[story objectForKey:@"intelligence"]];
// cell.storyScore = score;
//
// cell.isRead = ![storiesCollection isStoryUnread:story];
// cell.isReadAvailable = ![storiesCollection.activeFolder isEqualToString:@"saved_stories"];
// cell.textSize = self.textSize;
// cell.isShort = NO;
//
// UIInterfaceOrientation orientation = self.view.window.windowScene.interfaceOrientation;
// if (!self.isPhoneOrCompact &&
// !appDelegate.detailViewController.storyTitlesOnLeft &&
// UIInterfaceOrientationIsPortrait(orientation)) {
// cell.isShort = YES;
// }
//
// cell.isRiverOrSocial = NO;
// if (storiesCollection.isRiverView ||
// storiesCollection.isSavedView ||
// storiesCollection.isReadView ||
// storiesCollection.isWidgetView ||
// storiesCollection.isSocialView ||
// storiesCollection.isSocialRiverView) {
// cell.isRiverOrSocial = YES;
// }
}
}
extension Story: Equatable {
static func == (lhs: Story, rhs: Story) -> Bool {
return lhs.id == rhs.id
}
}
extension Story: CustomDebugStringConvertible {
var debugDescription: String {
return "Story \"\(title)\" in \(feedName)"
}
}
class StoryCache: ObservableObject {
let appDelegate = NewsBlurAppDelegate.shared!
var isGrid: Bool {
return appDelegate.detailViewController.layout == .grid
}
@Published var before = [Story]()
@Published var selected: Story?
@Published var after = [Story]()
func appendStories(beforeSelection: [Int], selectedIndex: Int, afterSelection: [Int]) {
before = beforeSelection.map { Story(index: $0) }
selected = selectedIndex >= 0 ? Story(index: selectedIndex) : nil
after = afterSelection.map { Story(index: $0) }
}
}
struct CardView: View {
let cache: StoryCache
let story: Story
var previewImage: UIImage? {
guard let image = cache.appDelegate.cachedImage(forStoryHash: story.hash), image.isKind(of: UIImage.self) else {
return nil
}
return image
}
var body: some View {
if cache.isGrid {
ZStack {
RoundedRectangle(cornerRadius: 12).foregroundColor(.init(white: 0.9))
VStack {
// RoundedRectangle(cornerRadius: 12).foregroundColor(.random)
// .frame(height: 200)
if let previewImage {
Image(uiImage: previewImage)
.resizable()
.scaledToFill()
.frame(height: 200)
// .clipped()
// .clipShape(RoundedRectangle(cornerRadius: 12))
.cornerRadius(12, corners: [.topLeft, .topRight])
.padding(0)
}
CardContentView(story: story)
.frame(maxHeight: .infinity, alignment: .leading)
.padding(10)
.padding(.leading, 20)
}
}
} else {
ZStack {
if story.isSelected {
RoundedRectangle(cornerRadius: 12).foregroundColor(.init(white: 0.9))
}
HStack {
CardContentView(story: story)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.leading, 20)
if let previewImage {
// RoundedRectangle(cornerRadius: 12).foregroundColor(.random)
Image(uiImage: previewImage)
.resizable()
.scaledToFill()
.frame(width: 80)
.clipShape(RoundedRectangle(cornerRadius: 10))
// .clipped()
}
}
.if(story.isSelected) { view in
view.padding(10)
}
}
}
}
}
//struct CardView_Previews: PreviewProvider {
// static var previews: some View {
// CardView(cache: StoryCache(), story: Story(index: 0))
// }
//}
struct CardContentView: View {
let story: Story
var body: some View {
VStack(alignment: .leading) {
Text(story.title)
.font(.custom("WhitneySSm-Medium", size: 18, relativeTo: .caption).bold())
Text(story.content.prefix(400))
.font(.custom("WhitneySSm-Book", size: 13, relativeTo: .caption))
.padding(.top, 5)
Spacer()
Text(story.dateAndAuthor)
.font(.custom("WhitneySSm-Medium", size: 10, relativeTo: .caption))
.padding(.top, 5)
}
}
}
struct StoryPagesView: UIViewControllerRepresentable {
typealias UIViewControllerType = StoryPagesViewController
let appDelegate = NewsBlurAppDelegate.shared!
func makeUIViewController(context: Context) -> StoryPagesViewController {
appDelegate.detailViewController.prepareStoriesForGridView()
return appDelegate.storyPagesViewController
}
func updateUIViewController(_ storyPagesViewController: StoryPagesViewController, context: Context) {
storyPagesViewController.updatePage(withActiveStory: appDelegate.storiesCollection.locationOfActiveStory(), updateFeedDetail: false)
}
}
struct StoryView: View {
let story: Story
var body: some View {
VStack {
Text(story.title)
StoryPagesView()
}
}
}
extension Color {
static var random: Color {
return Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}
extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition {
transform(self)
} else {
self
}
}
}
extension View {
@ViewBuilder
func modify<Content: View>(@ViewBuilder _ transform: (Self) -> Content?) -> some View {
if let view = transform(self), !(view is EmptyView) {
view
} else {
self
}
}
}
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape( RoundedCorner(radius: radius, corners: corners) )
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius))
return Path(path.cgPath)
}
}
//extension View {
// @ViewBuilder
// func swipe() -> some View {
// if #available(iOS 15, *) {
// self.swipeActions {
// Button("Order") {
// print("Awesome!")
// }
// .tint(.green)
// }
// }
// }
//}
protocol FeedDetailInteraction {
func storyAppeared(_ story: Story)
func storyTapped(_ story: Story)
}
struct FeedDetailGridView: View {
var feedDetailInteraction: FeedDetailInteraction
@ObservedObject var cache: StoryCache
var columns: [GridItem] {
if cache.isGrid {
return [GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20),
GridItem(.flexible(), spacing: 20),
]
} else {
return [GridItem(.flexible()),
]
}
}
var isOS15OrLater: Bool {
if #available(iOS 15.0, *) {
return true
} else {
return false
}
}
var cardHeight: CGFloat {
//TODO: 🚧 switch based on grid card height
return 400
}
var storyHeight: CGFloat {
//TODO: 🚧 determine ideal height of story view
return 1000
}
// let stories: [Story] = StoryCache.stories
var body: some View {
ScrollView {
LazyVGrid(columns: columns, spacing: 20) {
Section {
ForEach(cache.before) { story in
makeCardView(for: story, cache: cache)
}
}
if !cache.isGrid, let story = cache.selected {
makeCardView(for: story, cache: cache)
}
Section(header: makeStoryView(cache: cache)) {
ForEach(cache.after) { story in
makeCardView(for: story, cache: cache)
}
}
}
.padding()
}
}
func makeCardView(for story: Story, cache: StoryCache) -> some View {
return CardView(cache: cache, story: self.loaded(story: story))
.onAppear {
feedDetailInteraction.storyAppeared(story)
}
.onTapGesture {
feedDetailInteraction.storyTapped(story)
}
.if(cache.isGrid) { view in
view.frame(height: cardHeight)
}
}
@ViewBuilder
func makeStoryView(cache: StoryCache) -> some View {
if cache.isGrid, let story = cache.selected {
StoryView(story: story)
.frame(height: storyHeight)
}
}
func loaded(story: Story) -> Story {
story.load()
return story
}
}
//struct FeedDetailGridView_Previews: PreviewProvider {
// static var previews: some View {
// FeedDetailGridView(feedDetailInteraction: FeedDetailViewController(), storyCache: StoryCache())
// }
//}

View file

@ -7,9 +7,14 @@
//
import UIKit
import SwiftUI
/// List of stories for a feed.
class FeedDetailViewController: FeedDetailObjCViewController {
lazy var gridViewController = makeGridViewController()
lazy var storyCache = StoryCache()
enum SectionLayoutKind: Int, CaseIterable {
/// Feed cells before the story.
case feedBeforeStory
@ -77,10 +82,32 @@ class FeedDetailViewController: FeedDetailObjCViewController {
var dataSource: UICollectionViewDiffableDataSource<SectionLayoutKind, Int>! = nil
private func makeGridViewController() -> UIHostingController<FeedDetailGridView> {
// let headerView = FeedDetailGridView(isGrid: isGrid, storyCache: storyCache)
let gridView = FeedDetailGridView(feedDetailInteraction: self, cache: storyCache)
let gridViewController = UIHostingController(rootView: gridView)
gridViewController.view.translatesAutoresizingMaskIntoConstraints = false
return gridViewController
}
override func viewDidLoad() {
super.viewDidLoad()
changedLayout()
configureDataSource()
feedCollectionView.isHidden = true
addChild(gridViewController)
view.addSubview(gridViewController.view)
gridViewController.didMove(toParent: self)
NSLayoutConstraint.activate([
gridViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
gridViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
gridViewController.view.widthAnchor.constraint(equalTo: view.widthAnchor),
gridViewController.view.heightAnchor.constraint(equalTo: view.heightAnchor)
])
}
@objc override func changedLayout() {
@ -248,28 +275,64 @@ extension FeedDetailViewController {
var snapshot = NSDiffableDataSourceSnapshot<SectionLayoutKind, Int>()
let storyCount = Int(appDelegate.storiesCollection.storyLocationsCount)
var beforeSelection = [Int]()
var selectedIndex = -999
var afterSelection = [Int]()
snapshot.appendSections(SectionLayoutKind.allCases)
if self.messageView.isHidden {
if storyCount > 0 {
let selectedIndex = appDelegate.storiesCollection.indexOfActiveStory()
selectedIndex = appDelegate.storiesCollection.indexOfActiveStory()
if selectedIndex < 0 {
snapshot.appendItems(Array(0..<storyCount), toSection: .feedBeforeStory)
beforeSelection = Array(0..<storyCount)
snapshot.appendItems(beforeSelection, toSection: .feedBeforeStory)
} else {
snapshot.appendItems(Array(0..<selectedIndex), toSection: .feedBeforeStory)
beforeSelection = Array(0..<selectedIndex)
snapshot.appendItems(beforeSelection, toSection: .feedBeforeStory)
snapshot.appendItems([selectedIndex], toSection: .selectedStory)
if selectedIndex + 1 < storyCount {
snapshot.appendItems(Array(selectedIndex + 1..<storyCount), toSection: .feedAfterStory)
afterSelection = Array(selectedIndex + 1..<storyCount)
snapshot.appendItems(afterSelection, toSection: .feedAfterStory)
}
}
}
snapshot.appendItems([-1], toSection: .loading)
//TODO: 🚧 move the above logic into StoryCache
storyCache.appendStories(beforeSelection: beforeSelection, selectedIndex: selectedIndex, afterSelection: afterSelection)
}
dataSource.apply(snapshot, animatingDifferences: false)
}
}
extension FeedDetailViewController: FeedDetailInteraction {
func storyAppeared(_ story: Story) {
print("\(story.title) appeared")
//TODO: 🚧: this logic is from checkScroll; some more stuff there that may be needed
if story.index >= storyCache.before.count + storyCache.after.count - 5 {
if storiesCollection.isRiverView, storiesCollection.activeFolder != nil {
fetchRiverPage(storiesCollection.feedPage + 1, withCallback: nil)
} else {
fetchFeedDetail(storiesCollection.feedPage + 1, withCallback: nil)
}
}
}
func storyTapped(_ story: Story) {
print("tapped \(story.title)")
let indexPath = IndexPath(row: story.index, section: 0)
//TODO: 🚧 change this function to work better with the grid view
collectionView(feedCollectionView, didSelectItemAt: indexPath)
}
}

View file

@ -157,6 +157,8 @@ SFSafariViewControllerDelegate> {
PINCache *cachedStoryImages;
}
@property (class, nonatomic) NewsBlurAppDelegate *shared;
@property (nonatomic) SplitViewController *splitViewController;
@property (nonatomic) IBOutlet UINavigationController *ftuxNavigationController;
@property (nonatomic) IBOutlet UINavigationController *feedsNavigationController;

View file

@ -196,6 +196,10 @@
return (NewsBlurAppDelegate *)[UIApplication sharedApplication].delegate;
}
+ (instancetype)shared {
return (NewsBlurAppDelegate *)[UIApplication sharedApplication].delegate;
}
- (BOOL)application:(UIApplication *)application willFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[self registerDefaultsFromSettingsBundle];

View file

@ -28,6 +28,8 @@
172AD264251D901D000BB264 /* HorizontalPageDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172AD263251D901D000BB264 /* HorizontalPageDelegate.swift */; };
172AD274251D9F40000BB264 /* Storyboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 172AD273251D9F40000BB264 /* Storyboards.swift */; };
17362ADD23639B4E00A0FCCC /* OfflineFetchText.m in Sources */ = {isa = PBXBuildFile; fileRef = 17362ADC23639B4E00A0FCCC /* OfflineFetchText.m */; };
1737A9E22979EA8700E84348 /* FeedDetailGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1737A9E12979EA8700E84348 /* FeedDetailGridView.swift */; };
1737A9E32979EA8700E84348 /* FeedDetailGridView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1737A9E12979EA8700E84348 /* FeedDetailGridView.swift */; };
173CB30F26BCE94700BA872A /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 173CB30E26BCE94700BA872A /* WidgetKit.framework */; };
173CB31126BCE94700BA872A /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 173CB31026BCE94700BA872A /* SwiftUI.framework */; };
173CB31426BCE94700BA872A /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 173CB31326BCE94700BA872A /* WidgetExtension.swift */; };
@ -1396,6 +1398,7 @@
172AD273251D9F40000BB264 /* Storyboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storyboards.swift; sourceTree = "<group>"; };
17362ADB23639B4E00A0FCCC /* OfflineFetchText.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = OfflineFetchText.h; path = offline/OfflineFetchText.h; sourceTree = "<group>"; };
17362ADC23639B4E00A0FCCC /* OfflineFetchText.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = OfflineFetchText.m; path = offline/OfflineFetchText.m; sourceTree = "<group>"; };
1737A9E12979EA8700E84348 /* FeedDetailGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedDetailGridView.swift; sourceTree = "<group>"; };
173CB30D26BCE94700BA872A /* NewsBlur Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NewsBlur Widget.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
173CB30E26BCE94700BA872A /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
173CB31026BCE94700BA872A /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
@ -2683,6 +2686,7 @@
170E3CD624F8AB0D009CE819 /* FeedDetailViewController.swift */,
FF4D9074265BE13500792DB3 /* FeedDetailObjCViewController.h */,
FF4D9073265BE13500792DB3 /* FeedDetailObjCViewController.m */,
1737A9E12979EA8700E84348 /* FeedDetailGridView.swift */,
17435D302942DBBA002C126C /* FeedDetailCollectionCell.swift */,
7843F50311EEB1A000675F64 /* FeedDetailCollectionCellObsoleteObjCEdition.h */,
7843F50411EEB1A000675F64 /* FeedDetailCollectionCellObsoleteObjCEdition.m */,
@ -4895,6 +4899,7 @@
175792702930605500490924 /* THCircularProgressView.m in Sources */,
175792712930605500490924 /* IASKSpecifier.m in Sources */,
175792722930605500490924 /* UIView+ViewController.m in Sources */,
1737A9E32979EA8700E84348 /* FeedDetailGridView.swift in Sources */,
175792732930605500490924 /* PINCache.m in Sources */,
175792742930605500490924 /* FeedChooserViewCell.m in Sources */,
175792752930605500490924 /* UIView+TKCategory.m in Sources */,
@ -5072,6 +5077,7 @@
FFD6604C1BACA45D006E4B8D /* THCircularProgressView.m in Sources */,
FF34FD681E9D93CB0062F8ED /* IASKSpecifier.m in Sources */,
FFA0484419CA73B700618DC4 /* UIView+ViewController.m in Sources */,
1737A9E22979EA8700E84348 /* FeedDetailGridView.swift in Sources */,
FF2924E51E932D2900FCFA63 /* PINCache.m in Sources */,
17432C891C534BC6003F8FD6 /* FeedChooserViewCell.m in Sources */,
43A4C3E515B00966008787B5 /* UIView+TKCategory.m in Sources */,

View file

@ -10,6 +10,7 @@
#import <UIKit/UIKit.h>
#import "NSString+HTML.h"
#import "NewsBlurAppDelegate.h"
#import "ThemeManager.h"
#import "StoriesCollection.h"