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
//
// 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.
//
#if os(macOS)
import SwiftUI
import NukeUI
struct MacSubscriptionsSidebarRow: View {
struct SubscriptionsSidebarRow: View {
let name: String
let avatarURL: URL?
let serverURL: URL?
let authHeader: String?
let channelID: String?
let isAllChannels: Bool
var isSelected: Bool = false
private let avatarSize: CGFloat = 28
@@ -37,6 +37,7 @@ struct MacSubscriptionsSidebarRow: View {
.clipShape(Circle())
Text(name)
.fontWeight(isSelected ? .semibold : .regular)
.lineLimit(1)
Spacer(minLength: 0)
@@ -74,4 +75,3 @@ struct MacSubscriptionsSidebarRow: View {
}
}
}
#endif

View File

@@ -13,6 +13,9 @@ struct SubscriptionsView: View {
#if os(tvOS)
@FocusState private var focusedVideoID: String?
#endif
#if os(iOS)
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
#endif
@State private var feedCache = SubscriptionFeedCache.shared
@State private var subscriptions: [Subscription] = []
@State private var subscriptionsLoaded = false
@@ -239,29 +242,41 @@ struct SubscriptionsView: View {
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
}
#else
Group {
switch layout {
case .list:
listContent
case .grid:
gridContent
if isIPadRegular && subscriptionsLoaded && subscriptions.count > 1 {
HStack(spacing: 0) {
iOSChannelsSidebar
.frame(width: 260)
Divider()
feedLayout
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.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)
.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)
}
} 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
// Bottom overlay for filter strip (iOS only)
// Bottom overlay for filter strip (iOS only, compact width)
#if os(iOS)
VStack {
Spacer()
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError {
if subscriptionsLoaded && subscriptions.count > 1 && channelStripSize != .disabled && !isShowingFullScreenError && !isIPadRegular {
bottomFloatingFilterStrip
.transition(.move(edge: .bottom).combined(with: .opacity))
}
@@ -270,7 +285,11 @@ struct SubscriptionsView: View {
}
#if !os(tvOS)
.navigationTitle(String(localized: "tabs.subscriptions"))
#if os(iOS)
.toolbarTitleDisplayMode(isIPadRegular ? .inline : .inlineLarge)
#else
.toolbarTitleDisplayMode(.inlineLarge)
#endif
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
@@ -742,70 +761,185 @@ struct SubscriptionsView: View {
}
#endif
// MARK: - macOS Channels Sidebar
// MARK: - Channels Sidebar (macOS / iPad)
#if os(macOS)
private static let macOSAllChannelsTag = "__all__"
#if !os(tvOS)
private static let sidebarAllChannelsTag = "__all__"
private var macOSSidebarSelection: Binding<String?> {
private var sidebarSelection: Binding<String?> {
Binding(
get: { selectedChannelID ?? Self.macOSAllChannelsTag },
get: { selectedChannelID ?? Self.sidebarAllChannelsTag },
set: { newValue in
selectedChannelID = (newValue == Self.macOSAllChannelsTag) ? nil : newValue
selectedChannelID = (newValue == Self.sidebarAllChannelsTag) ? nil : newValue
}
)
}
private var macOSChannelsSidebar: some View {
List(selection: macOSSidebarSelection) {
MacSubscriptionsSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true
@ViewBuilder
private var channelsSidebarContent: some View {
SubscriptionsSidebarRow(
name: String(localized: "subscriptions.allChannels"),
avatarURL: nil,
serverURL: nil,
authHeader: nil,
channelID: nil,
isAllChannels: true
)
.tag(Self.sidebarAllChannelsTag)
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
} label: {
Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape")
}
}
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
SubscriptionsSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false
)
.tag(Self.macOSAllChannelsTag)
.tag(Optional(subscription.channelID))
.contextMenu {
Button {
appEnvironment?.navigationCoordinator.navigate(to: .manageChannels)
appEnvironment?.navigationCoordinator.navigate(
to: .channel(subscription.channelID, subscription.contentSource)
)
} label: {
Label(String(localized: "sidebar.manageChannels"), systemImage: "person.2.badge.gearshape")
Label(String(localized: "subscriptions.goToChannel"), systemImage: "person.circle")
}
}
ForEach(sortedSubscriptions, id: \.channelID) { subscription in
MacSubscriptionsSidebarRow(
name: subscription.name,
avatarURL: subscription.avatarURL,
serverURL: yatteeServerURL,
authHeader: yatteeServerAuthHeader,
channelID: subscription.channelID,
isAllChannels: false
)
.tag(Optional(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")
}
Button(role: .destructive) {
unsubscribeChannel(subscription.channelID)
} label: {
Label(String(localized: "channel.unsubscribe"), systemImage: "person.badge.minus")
}
}
}
}
#endif
#if os(macOS)
private var macOSChannelsSidebar: some View {
List(selection: sidebarSelection) {
channelsSidebarContent
}
.listStyle(.sidebar)
.scrollContentBackground(.hidden)
}
#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
private var subscriptionsQueueSource: QueueSource {