2023-01-21 16:20:53 -06:00
|
|
|
//
|
|
|
|
// FeedDetailGridView.swift
|
|
|
|
// NewsBlur
|
|
|
|
//
|
|
|
|
// Created by David Sinclair on 2023-01-19.
|
|
|
|
// Copyright © 2023 NewsBlur. All rights reserved.
|
|
|
|
//
|
|
|
|
|
|
|
|
import SwiftUI
|
|
|
|
|
2023-02-02 21:41:10 -06:00
|
|
|
/// A protocol of interaction between a card in the grid, and the enclosing feed detail view controller.
|
2023-01-21 16:20:53 -06:00
|
|
|
protocol FeedDetailInteraction {
|
2023-03-03 21:38:55 -07:00
|
|
|
var storyHeight: CGFloat { get }
|
2023-05-26 16:01:51 -07:00
|
|
|
var hasNoMoreStories: Bool { get }
|
|
|
|
var isPremiumRestriction: Bool { get }
|
2023-06-15 21:53:00 -07:00
|
|
|
var isMarkReadOnScroll: Bool { get }
|
2023-03-03 21:38:55 -07:00
|
|
|
|
2023-04-21 21:44:28 -07:00
|
|
|
func pullToRefresh()
|
2023-03-03 21:38:55 -07:00
|
|
|
func visible(story: Story)
|
|
|
|
func tapped(story: Story)
|
|
|
|
func reading(story: Story)
|
2023-03-21 21:54:21 -07:00
|
|
|
func read(story: Story)
|
2023-06-15 21:53:00 -07:00
|
|
|
func unread(story: Story)
|
2023-03-03 21:38:55 -07:00
|
|
|
func hid(story: Story)
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
2023-02-02 21:41:10 -06:00
|
|
|
/// A list or grid layout of story cards for the feed detail view.
|
2023-01-21 16:20:53 -06:00
|
|
|
struct FeedDetailGridView: View {
|
|
|
|
var feedDetailInteraction: FeedDetailInteraction
|
|
|
|
|
|
|
|
@ObservedObject var cache: StoryCache
|
|
|
|
|
2023-03-21 21:54:21 -07:00
|
|
|
@State private var scrollOffset = CGPoint()
|
|
|
|
|
|
|
|
let storyViewID = "storyViewID"
|
|
|
|
|
2023-01-21 16:20:53 -06:00
|
|
|
var columns: [GridItem] {
|
|
|
|
if cache.isGrid {
|
2023-02-02 21:41:10 -06:00
|
|
|
return Array(repeating: GridItem(.flexible(), spacing: 20), count: cache.settings.gridColumns)
|
2023-01-21 16:20:53 -06:00
|
|
|
} else {
|
2023-02-02 21:41:10 -06:00
|
|
|
return [GridItem(.flexible())]
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var isOS15OrLater: Bool {
|
|
|
|
if #available(iOS 15.0, *) {
|
|
|
|
return true
|
|
|
|
} else {
|
|
|
|
return false
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var cardHeight: CGFloat {
|
2023-02-02 21:41:10 -06:00
|
|
|
return cache.settings.gridHeight
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
var storyHeight: CGFloat {
|
2023-08-29 13:00:23 -06:00
|
|
|
print("🐓 Story height: \(feedDetailInteraction.storyHeight + 20)")
|
2023-03-03 21:38:55 -07:00
|
|
|
|
|
|
|
return feedDetailInteraction.storyHeight + 20
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
var body: some View {
|
2023-03-21 21:54:21 -07:00
|
|
|
GeometryReader { reader in
|
|
|
|
ScrollView {
|
|
|
|
ScrollViewReader { scroller in
|
|
|
|
LazyVGrid(columns: columns, spacing: cache.isGrid ? 20 : 0) {
|
2023-06-15 21:53:00 -07:00
|
|
|
if cache.isPhone {
|
|
|
|
Section(footer: makeLoadingView()) {
|
|
|
|
ForEach(cache.before, id: \.id) { story in
|
|
|
|
makeCardView(for: story, reader: reader)
|
|
|
|
}
|
|
|
|
|
|
|
|
if let story = cache.selected {
|
|
|
|
makeCardView(for: story, reader: reader)
|
|
|
|
.id(story.id)
|
|
|
|
}
|
|
|
|
|
|
|
|
ForEach(cache.after, id: \.id) { story in
|
|
|
|
makeCardView(for: story, reader: reader)
|
|
|
|
}
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
2023-06-15 21:53:00 -07:00
|
|
|
} else {
|
|
|
|
Section {
|
|
|
|
ForEach(cache.before, id: \.id) { story in
|
|
|
|
makeCardView(for: story, reader: reader)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if cache.isGrid && !cache.isPhone {
|
|
|
|
EmptyView()
|
|
|
|
.id(storyViewID)
|
|
|
|
} else if let story = cache.selected {
|
2023-05-26 16:01:51 -07:00
|
|
|
makeCardView(for: story, reader: reader)
|
2023-06-15 21:53:00 -07:00
|
|
|
.id(story.id)
|
|
|
|
}
|
|
|
|
|
|
|
|
Section(header: makeStoryView(), footer: makeLoadingView()) {
|
|
|
|
ForEach(cache.after, id: \.id) { story in
|
|
|
|
makeCardView(for: story, reader: reader)
|
|
|
|
}
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
|
|
|
}
|
2023-03-03 21:38:55 -07:00
|
|
|
}
|
2023-03-21 21:54:21 -07:00
|
|
|
.onChange(of: cache.selected) { [oldSelected = cache.selected] newSelected in
|
2023-08-11 11:40:13 -06:00
|
|
|
guard oldSelected?.hash != newSelected?.hash else {
|
2023-03-21 21:54:21 -07:00
|
|
|
return
|
|
|
|
}
|
|
|
|
|
2023-08-29 13:00:23 -06:00
|
|
|
print("🪿 Selection: '\(oldSelected?.title ?? "none")' -> '\(newSelected?.title ?? "none")'")
|
2023-03-21 21:54:21 -07:00
|
|
|
|
|
|
|
Task {
|
2023-08-11 11:40:13 -06:00
|
|
|
if newSelected == nil, !cache.isPhone, let oldSelected, let story = cache.story(with: oldSelected.index) {
|
|
|
|
scroller.scrollTo(story.id, anchor: .top)
|
|
|
|
} else if let newSelected, !cache.isGrid {
|
2023-03-21 21:54:21 -07:00
|
|
|
withAnimation(Animation.spring().delay(0.5)) {
|
2023-07-04 21:25:35 -07:00
|
|
|
scroller.scrollTo(newSelected.id)
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
2023-07-04 21:25:35 -07:00
|
|
|
} else if !cache.isPhone {
|
2023-03-21 21:54:21 -07:00
|
|
|
withAnimation(Animation.spring().delay(0.5)) {
|
2023-07-04 21:25:35 -07:00
|
|
|
scroller.scrollTo(storyViewID, anchor: .top)
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
2023-08-11 11:40:13 -06:00
|
|
|
} else if let newSelected {
|
2023-07-04 21:25:35 -07:00
|
|
|
scroller.scrollTo(newSelected.id, anchor: .top)
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
2023-03-03 21:38:55 -07:00
|
|
|
}
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
2023-04-19 21:35:34 -07:00
|
|
|
.onAppear() {
|
|
|
|
if cache.isGrid {
|
|
|
|
scroller.scrollTo(storyViewID, anchor: .top)
|
|
|
|
}
|
|
|
|
}
|
2023-03-21 21:54:21 -07:00
|
|
|
.if(cache.isGrid) { view in
|
|
|
|
view.padding()
|
|
|
|
}
|
2023-03-03 21:38:55 -07:00
|
|
|
}
|
2023-02-02 21:41:10 -06:00
|
|
|
}
|
2023-04-21 21:44:28 -07:00
|
|
|
.modify({ view in
|
|
|
|
if #available(iOS 15.0, *) {
|
|
|
|
view.refreshable {
|
|
|
|
if cache.canPullToRefresh {
|
|
|
|
feedDetailInteraction.pullToRefresh()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
})
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
2023-07-20 21:07:56 -06:00
|
|
|
.background(Color.themed([0xE0E0E0, 0xFFF8CA, 0x363636, 0x101010]))
|
2023-07-21 11:50:47 -06:00
|
|
|
.if(cache.isGrid) { view in
|
|
|
|
view.lazyPop()
|
|
|
|
}
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
2023-02-02 21:41:10 -06:00
|
|
|
@ViewBuilder
|
2023-05-26 16:01:51 -07:00
|
|
|
func makeCardView(for story: Story, reader: GeometryProxy) -> some View {
|
2023-08-29 13:38:45 -06:00
|
|
|
CardView(feedDetailInteraction: feedDetailInteraction, cache: cache, story: story)
|
2023-03-21 21:54:21 -07:00
|
|
|
.transformAnchorPreference(key: CardKey.self, value: .bounds) {
|
|
|
|
$0.append(CardFrame(id: "\(story.id)", frame: reader[$1]))
|
|
|
|
}
|
|
|
|
.onPreferenceChange(CardKey.self) {
|
2023-06-15 21:53:00 -07:00
|
|
|
if feedDetailInteraction.isMarkReadOnScroll, let value = $0.first, value.frame.minY < -(value.frame.size.height / 2) {
|
2023-08-29 13:00:23 -06:00
|
|
|
print("🐓 Scrolled off the top: \(story.debugTitle): \($0)")
|
2023-03-21 21:54:21 -07:00
|
|
|
|
2023-06-15 21:53:00 -07:00
|
|
|
// withAnimation(Animation.spring().delay(2)) {
|
|
|
|
feedDetailInteraction.read(story: story)
|
|
|
|
// }
|
2023-03-21 21:54:21 -07:00
|
|
|
}
|
|
|
|
}
|
2023-01-21 16:20:53 -06:00
|
|
|
.onAppear {
|
2023-03-03 21:38:55 -07:00
|
|
|
feedDetailInteraction.visible(story: story)
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
.if(cache.isGrid) { view in
|
|
|
|
view.frame(height: cardHeight)
|
|
|
|
}
|
2023-06-15 21:53:00 -07:00
|
|
|
.gesture(DragGesture(minimumDistance: 50.0, coordinateSpace: .local)
|
|
|
|
.onEnded { value in
|
|
|
|
switch(value.translation.width, value.translation.height) {
|
|
|
|
case (...0, -30...30):
|
|
|
|
feedDetailInteraction.read(story: story)
|
|
|
|
case (0..., -30...30):
|
|
|
|
feedDetailInteraction.unread(story: story)
|
|
|
|
// case (-100...100, ...0): print("up swipe")
|
|
|
|
// case (-100...100, 0...): print("down swipe")
|
|
|
|
default: break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
)
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
@ViewBuilder
|
2023-05-26 16:01:51 -07:00
|
|
|
func makeStoryView() -> some View {
|
2023-03-30 10:36:31 -07:00
|
|
|
if cache.isGrid, !cache.isPhone, let story = cache.selected {
|
2023-08-29 13:38:45 -06:00
|
|
|
StoryView(cache: cache, story: story, interaction: feedDetailInteraction)
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-04-21 21:44:28 -07:00
|
|
|
@ViewBuilder
|
2023-05-26 16:01:51 -07:00
|
|
|
func makeLoadingView() -> some View {
|
|
|
|
FeedDetailLoadingView(feedDetailInteraction: feedDetailInteraction, cache: cache)
|
|
|
|
.id(UUID())
|
2023-04-21 21:44:28 -07:00
|
|
|
}
|
2023-01-21 16:20:53 -06:00
|
|
|
}
|
|
|
|
|
2023-03-21 21:54:21 -07:00
|
|
|
struct CardFrame : Equatable {
|
|
|
|
let id : String
|
|
|
|
let frame : CGRect
|
|
|
|
|
|
|
|
static func == (lhs: CardFrame, rhs: CardFrame) -> Bool {
|
|
|
|
lhs.id == rhs.id && lhs.frame == rhs.frame
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
struct CardKey : PreferenceKey {
|
|
|
|
typealias Value = [CardFrame]
|
|
|
|
|
|
|
|
static var defaultValue: [CardFrame] = []
|
|
|
|
|
|
|
|
static func reduce(value: inout [CardFrame], nextValue: () -> [CardFrame]) {
|
|
|
|
value.append(contentsOf: nextValue())
|
|
|
|
}
|
|
|
|
}
|