Files
yattee/Shared/Subscriptions/FeedView.swift
Arkadiusz Fal f4d4daccd0 Optimize SwiftUI performance throughout the app
This commit addresses multiple SwiftUI performance bottlenecks identified
through code analysis, focusing on view rendering efficiency, list
performance, and memory usage optimization.

Key improvements:

- HomeView: Optimize async task management using structured concurrency
  with async let to handle multiple Defaults updates in a single task

- VideoCell: Remove GeometryReader from VideoCellThumbnail to eliminate
  layout thrashing; change @ObservedObject to computed property for shared
  ThumbnailsModel

- ThumbnailView: Cache URL extension computation in init() instead of
  recalculating on every body evaluation

- FavoriteItemView: Replace filter().prefix() with early-exit loop and
  capacity reservation for significant performance gain on large lists

- ContentItemView: Optimize FetchRequest creation with direct predicate
  construction only for video items, empty predicate for others

- VideoPlayerView: Fix playerSize didSet trigger by moving
  updateSidebarQueue() calls to explicit onChange/onAppear handlers

- FeedView: Replace .unique() with Set-based deduplication for O(n)
  performance and reduced allocations

- VerticalCells: Remove expensive sorting on every redraw; items should
  be pre-sorted from source

These optimizations follow SwiftUI best practices by minimizing expensive
computations in view bodies, caching computed values, using efficient data
structures, and avoiding unnecessary redraws and layout passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:14:35 +01:00

339 lines
13 KiB
Swift

import Defaults
import Siesta
import SwiftUI
struct FeedView: View {
@ObservedObject private var feed = FeedModel.shared
@ObservedObject private var subscriptions = SubscribedChannelsModel.shared
@ObservedObject private var feedCount = UnwatchedFeedCountModel.shared
@Default(.showCacheStatus) private var showCacheStatus
#if os(tvOS)
@Default(.subscriptionsListingStyle) private var subscriptionsListingStyle
@StateObject private var accountsModel = AccountsViewModel()
#endif
var videos: [ContentItem] {
let feedVideos = feed.videos
guard let selectedChannel else {
return ContentItem.array(of: feedVideos)
}
return ContentItem.array(of: feedVideos.filter { $0.channel.id == selectedChannel.id })
}
var channels: [Channel] {
// Optimize by using a Set for uniqueness instead of calling .unique()
var seenIds = Set<String>()
var uniqueChannels = [Channel]()
uniqueChannels.reserveCapacity(feed.videos.count / 10) // Estimate
for video in feed.videos {
let channelId = video.channel.id
if !seenIds.contains(channelId) {
seenIds.insert(channelId)
uniqueChannels.append(video.channel)
}
}
return uniqueChannels
}
@State private var selectedChannel: Channel?
#if os(tvOS)
@FocusState private var focusedChannel: String?
#endif
@State private var feedChannelsViewVisible = false
private var navigation = NavigationModel.shared
private let dismiss_channel_list_id = "dismiss_channel_list_id"
var body: some View {
#if os(tvOS)
GeometryReader { geometry in
ZStack {
// selected channel feed view
HStack(spacing: 0) {
// sidebar - show channels
if feedChannelsViewVisible {
Spacer()
.frame(width: geometry.size.width * 0.3)
}
selectedFeedView
}
.disabled(feedChannelsViewVisible)
.frame(width: geometry.size.width, height: geometry.size.height)
if feedChannelsViewVisible {
HStack(spacing: 0) {
// sidebar - show channels
feedChannelsView
.padding(.all)
.frame(width: geometry.size.width * 0.3)
.background()
.clipShape(RoundedRectangle(cornerRadius: 16))
.contentShape(RoundedRectangle(cornerRadius: 16))
Rectangle()
.fill(.clear)
.id(dismiss_channel_list_id)
.focusable()
.focused(self.$focusedChannel, equals: dismiss_channel_list_id)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.clipped()
}
}
}
#else
selectedFeedView
#endif
}
#if os(tvOS)
var accountsPicker: some View {
ForEach(accountsModel.sortedAccounts.filter { $0.anonymous == false }) { account in
Button(action: {
AccountsModel.shared.setCurrent(account)
}) {
HStack {
Text("\(account.description) (\(account.instance.app.rawValue))")
if account == accountsModel.currentAccount {
Image(systemName: "checkmark")
}
}
}
.buttonStyle(PlainButtonStyle())
}
}
var feedChannelsView: some View {
ScrollViewReader { proxy in
VStack {
Text("Channels")
.font(.subheadline)
if #available(tvOS 17.0, *) {
List(selection: $selectedChannel) {
Button(action: {
self.selectedChannel = nil
self.feedChannelsViewVisible = false
}) {
HStack(spacing: 16) {
Image(systemName: RecentsModel.symbolSystemImage("A"))
.imageScale(.large)
.foregroundColor(.accentColor)
.frame(width: 35, height: 35)
Text("All")
Spacer()
feedCount.unwatchedText
}
}
.padding(.all)
.background(RoundedRectangle(cornerRadius: 8.0)
.fill(self.selectedChannel == nil ? Color.secondary : Color.clear))
.font(.caption)
.buttonStyle(PlainButtonStyle())
.focused(self.$focusedChannel, equals: "all")
ForEach(channels, id: \.self) { channel in
Button(action: {
self.selectedChannel = channel
self.feedChannelsViewVisible = false
}) {
HStack(spacing: 16) {
ChannelAvatarView(channel: channel, subscribedBadge: false)
.frame(width: 50, height: 50)
Text(channel.name)
.lineLimit(1)
Spacer()
if let unwatchedCount = feedCount.unwatchedByChannelText(channel) {
unwatchedCount
}
}
}
.padding(.all)
.background(RoundedRectangle(cornerRadius: 8.0)
.fill(self.selectedChannel == channel ? Color.secondary : Color.clear))
.font(.caption)
.buttonStyle(PlainButtonStyle())
.focused(self.$focusedChannel, equals: channel.id)
}
}
.onChange(of: self.focusedChannel) {
if self.focusedChannel == "all" {
withAnimation {
self.selectedChannel = nil
}
} else if self.focusedChannel == dismiss_channel_list_id {
self.feedChannelsViewVisible = false
} else {
withAnimation {
self.selectedChannel = channels.first {
$0.id == self.focusedChannel
}
}
}
}
.onAppear {
guard let selectedChannel = self.selectedChannel else {
return
}
proxy.scrollTo(selectedChannel, anchor: .top)
}
.onExitCommand {
withAnimation {
self.feedChannelsViewVisible = false
}
}
}
}
}
}
#endif
var selectedFeedView: some View {
VerticalCells(items: videos) { if shouldDisplayHeader { header } }
.environment(\.loadMoreContentHandler) { feed.loadNextPage() }
.onAppear {
feed.loadResources()
}
#if os(iOS)
.refreshControl { refreshControl in
feed.loadResources(force: true) {
refreshControl.endRefreshing()
}
}
.backport
.refreshable {
await feed.loadResources(force: true)
}
#endif
#if !os(tvOS)
.background(
Button("Refresh") {
feed.loadResources(force: true)
}
.keyboardShortcut("r")
.opacity(0)
)
#endif
#if !os(macOS)
.onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
feed.loadResources()
}
#endif
}
var header: some View {
HStack(spacing: 16) {
#if os(tvOS)
if #available(tvOS 17.0, *) {
Menu {
accountsPicker
} label: {
Label("Channels", systemImage: "filemenu.and.selection")
.labelStyle(.iconOnly)
.imageScale(.small)
.font(.caption)
} primaryAction: {
withAnimation {
self.feedChannelsViewVisible = true
self.focusedChannel = selectedChannel?.id ?? "all"
}
}
.opacity(feedChannelsViewVisible ? 0 : 1)
.frame(minWidth: feedChannelsViewVisible ? 0 : nil, maxWidth: feedChannelsViewVisible ? 0 : nil)
}
channelHeaderView
if selectedChannel == nil {
Spacer()
}
if feedChannelsViewVisible == false {
ListingStyleButtons(listingStyle: $subscriptionsListingStyle)
HideWatchedButtons()
HideShortsButtons()
}
#endif
if feedChannelsViewVisible == false {
if showCacheStatus {
CacheStatusHeader(
refreshTime: feed.formattedFeedTime,
isLoading: feed.isLoading
)
}
#if os(tvOS)
Button {
feed.loadResources(force: true)
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
.labelStyle(.iconOnly)
.imageScale(.small)
.font(.caption)
}
#endif
}
}
.padding(.leading, 30)
#if os(tvOS)
.padding(.bottom, 15)
.padding(.trailing, 30)
#endif
}
var channelHeaderView: some View {
guard let selectedChannel else {
return AnyView(
Text("All Channels")
.font(.caption)
.frame(alignment: .leading)
.lineLimit(1)
.padding(0)
.padding(.leading, 16)
)
}
return AnyView(
HStack(spacing: 16) {
ChannelAvatarView(channel: selectedChannel, subscribedBadge: false)
.id("channel-avatar-\(selectedChannel.id)")
.frame(width: 80, height: 80)
Text("\(selectedChannel.name)")
.font(.caption)
.frame(alignment: .leading)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
Spacer()
if feedChannelsViewVisible == false {
Button(action: {
navigation.openChannel(selectedChannel, navigationStyle: .tab)
}) {
Text("Visit Channel")
.font(.caption)
.frame(alignment: .leading)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
}
}
.padding(0)
.padding(.leading, 16)
)
}
var shouldDisplayHeader: Bool {
#if os(tvOS)
true
#else
showCacheStatus
#endif
}
}
struct FeedView_Previews: PreviewProvider {
static var previews: some View {
NavigationView {
FeedView()
}
}
}