Add channels sidebar to Subscriptions on iPad regular width

On iPad in regular horizontal size class, the Subscriptions view now
shows a channels column next to the feed (mirroring macOS/tvOS) instead
of the floating channel strip. iPhone and compact-width iPad keep the
existing strip.

- Renames MacSubscriptionsSidebarRow to SubscriptionsSidebarRow and
  shares it across macOS and iOS.
- Uses a custom ScrollView-based sidebar on iPad to avoid iOS 26's
  sidebar background extension bleeding into the selection highlight.
- Forces inline toolbar title on iPad regular so scrolling either
  column behaves consistently.
This commit is contained in:
Arkadiusz Fal
2026-04-19 17:48:17 +02:00
parent 5e205e4a4c
commit fe78261866
2 changed files with 197 additions and 63 deletions

View File

@@ -1,22 +1,22 @@
// //
// MacSubscriptionsSidebarRow.swift // SubscriptionsSidebarRow.swift
// Yattee // Yattee
// //
// Compact row used in the macOS Subscriptions sidebar for the // Compact row used in the Subscriptions sidebar (macOS and iPad) for the
// "All Channels" entry and each subscribed channel. // "All Channels" entry and each subscribed channel.
// //
#if os(macOS)
import SwiftUI import SwiftUI
import NukeUI import NukeUI
struct MacSubscriptionsSidebarRow: View { struct SubscriptionsSidebarRow: View {
let name: String let name: String
let avatarURL: URL? let avatarURL: URL?
let serverURL: URL? let serverURL: URL?
let authHeader: String? let authHeader: String?
let channelID: String? let channelID: String?
let isAllChannels: Bool let isAllChannels: Bool
var isSelected: Bool = false
private let avatarSize: CGFloat = 28 private let avatarSize: CGFloat = 28
@@ -37,6 +37,7 @@ struct MacSubscriptionsSidebarRow: View {
.clipShape(Circle()) .clipShape(Circle())
Text(name) Text(name)
.fontWeight(isSelected ? .semibold : .regular)
.lineLimit(1) .lineLimit(1)
Spacer(minLength: 0) Spacer(minLength: 0)
@@ -74,4 +75,3 @@ struct MacSubscriptionsSidebarRow: View {
} }
} }
} }
#endif

View File

@@ -13,6 +13,9 @@ struct SubscriptionsView: View {
#if os(tvOS) #if os(tvOS)
@FocusState private var focusedVideoID: String? @FocusState private var focusedVideoID: String?
#endif #endif
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@State private var feedCache = SubscriptionFeedCache.shared @State private var feedCache = SubscriptionFeedCache.shared
@State private var subscriptions: [Subscription] = [] @State private var subscriptions: [Subscription] = []
@State private var subscriptionsLoaded = false @State private var subscriptionsLoaded = false
@@ -239,13 +242,15 @@ struct SubscriptionsView: View {
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity) .frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
} }
#else #else
Group { if isIPadRegular && subscriptionsLoaded && subscriptions.count > 1 {
switch layout { HStack(spacing: 0) {
case .list: iOSChannelsSidebar
listContent .frame(width: 260)
case .grid:
gridContent Divider()
}
feedLayout
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
.refreshable { .refreshable {
guard let appEnvironment else { return } guard let appEnvironment else { return }
@@ -254,14 +259,24 @@ struct SubscriptionsView: View {
await feedCache.refresh(using: appEnvironment) await feedCache.refresh(using: appEnvironment)
LoggingService.shared.info("Pull-to-refresh completed", category: .general) LoggingService.shared.info("Pull-to-refresh completed", category: .general)
} }
} else {
feedLayout
.refreshable {
guard let appEnvironment else { return }
LoggingService.shared.info("User initiated pull-to-refresh in Subscriptions view", category: .general)
await loadSubscriptionsAsync()
await feedCache.refresh(using: appEnvironment)
LoggingService.shared.info("Pull-to-refresh completed", category: .general)
}
}
#endif #endif
// Bottom overlay for filter strip (iOS only) // Bottom overlay for filter strip (iOS only, compact width)
#if os(iOS) #if os(iOS)
VStack { VStack {
Spacer() Spacer()
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError { if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError && !isIPadRegular {
bottomFloatingFilterStrip bottomFloatingFilterStrip
.transition(.move(edge: .bottom).combined(with: .opacity)) .transition(.move(edge: .bottom).combined(with: .opacity))
} }
@@ -270,7 +285,11 @@ struct SubscriptionsView: View {
} }
#if !os(tvOS) #if !os(tvOS)
.navigationTitle(String(localized: "tabs.subscriptions")) .navigationTitle(String(localized: "tabs.subscriptions"))
#if os(iOS)
.toolbarTitleDisplayMode(isIPadRegular ? .inline : .inlineLarge)
#else
.toolbarTitleDisplayMode(.inlineLarge) .toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { Button {
@@ -742,23 +761,23 @@ struct SubscriptionsView: View {
} }
#endif #endif
// MARK: - macOS Channels Sidebar // MARK: - Channels Sidebar (macOS / iPad)
#if os(macOS) #if !os(tvOS)
private static let macOSAllChannelsTag = "__all__" private static let sidebarAllChannelsTag = "__all__"
private var macOSSidebarSelection: Binding<String?> { private var sidebarSelection: Binding<String?> {
Binding( Binding(
get: { selectedChannelID ?? Self.macOSAllChannelsTag }, get: { selectedChannelID ?? Self.sidebarAllChannelsTag },
set: { newValue in set: { newValue in
selectedChannelID = (newValue == Self.macOSAllChannelsTag) ? nil : newValue selectedChannelID = (newValue == Self.sidebarAllChannelsTag) ? nil : newValue
} }
) )
} }
private var macOSChannelsSidebar: some View { @ViewBuilder
List(selection: macOSSidebarSelection) { private var channelsSidebarContent: some View {
MacSubscriptionsSidebarRow( SubscriptionsSidebarRow(
name: String(localized: "subscriptions.allChannels"), name: String(localized: "subscriptions.allChannels"),
avatarURL: nil, avatarURL: nil,
serverURL: nil, serverURL: nil,
@@ -766,7 +785,7 @@ struct SubscriptionsView: View {
channelID: nil, channelID: nil,
isAllChannels: true isAllChannels: true
) )
.tag(Self.macOSAllChannelsTag) .tag(Self.sidebarAllChannelsTag)
.contextMenu { .contextMenu {
Button { Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels) appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
@@ -776,7 +795,7 @@ struct SubscriptionsView: View {
} }
ForEach(sortedSubscriptions, id: \.channelID) { subscription in ForEach(sortedSubscriptions, id: \.channelID) { subscription in
MacSubscriptionsSidebarRow( SubscriptionsSidebarRow(
name: subscription.name, name: subscription.name,
avatarURL: subscription.avatarURL, avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL, serverURL: yatteeServerURL,
@@ -801,11 +820,126 @@ struct SubscriptionsView: View {
} }
} }
} }
#endif
#if os(macOS)
private var macOSChannelsSidebar: some View {
List(selection: sidebarSelection) {
channelsSidebarContent
}
.listStyle(.sidebar) .listStyle(.sidebar)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
} }
#endif #endif
#if os(iOS)
private var isIPadRegular: Bool {
UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular
}
@ViewBuilder
private var feedLayout: some View {
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
}
}
}
private var iOSChannelsSidebar: some View {
ScrollView {
LazyVStack(spacing: 2) {
iOSSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true,
isSelected: selectedChannelID == nil
) {
if selectedChannelID != nil { selectedChannelID = nil }
}
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape")
}
}
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
iOSSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false,
isSelected: selectedChannelID == subscription.channelID
) {
if selectedChannelID == subscription.channelID {
selectedChannelID = nil
} else {
selectedChannelID = subscription.channelID
}
}
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
Label(String(localized: "subscriptions.goToChannel"), systemImage: "person.circle")
}
Button(role: .destructive) {
unsubscribeChannel(subscription.channelID)
} label: {
Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus")
}
}
}
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
@ViewBuilder
private func iOSSidebarRow(
name: String,
avatarURL: URL?,
serverURL: URL?,
authHeader: String?,
channelID: String?,
isAllChannels: Bool,
isSelected: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
SubscriptionsSidebarRow(
name: name,
avatarURL: avatarURL,
serverURL: serverURL,
authHeader: authHeader,
channelID: channelID,
isAllChannels: isAllChannels,
isSelected: isSelected
)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(isSelected ? accentColor.opacity(0.2) : Color.clear)
)
}
.buttonStyle(.plain)
}
#endif
// MARK: - Content Views // MARK: - Content Views
private var subscriptionsQueueSource: QueueSource { private var subscriptionsQueueSource: QueueSource {