Make sources list feel native on macOS

Drop the iOS-grouped rounded card, per-row chevron, and oversized
metrics on macOS. Use tighter padding, smaller icon/title fonts,
uppercase section headers, and top/bottom dividers so the list reads
like a native grouped Mac list. Force .buttonStyle(.plain) on row
buttons/NavigationLinks and add .contentShape(Rectangle()) so the
full row is hit-testable without picking up macOS's default link
styling. iOS and tvOS unchanged.
This commit is contained in:
Arkadiusz Fal
2026-04-20 21:07:05 +02:00
parent 267f770274
commit d8f10e984a
3 changed files with 136 additions and 12 deletions

View File

@@ -17,13 +17,25 @@ struct SourceListRow<Content: View>: View {
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
/// Horizontal padding for row content. /// Horizontal padding for row content.
#if os(macOS)
private let horizontalPadding: CGFloat = 12
#else
private let horizontalPadding: CGFloat = 16 private let horizontalPadding: CGFloat = 16
#endif
/// Vertical padding for row content. /// Vertical padding for row content.
#if os(macOS)
private let verticalPadding: CGFloat = 8
#else
private let verticalPadding: CGFloat = 12 private let verticalPadding: CGFloat = 12
#endif
/// Width of the icon column. /// Width of the icon column.
#if os(macOS)
private let iconWidth: CGFloat = 24
#else
private let iconWidth: CGFloat = 32 private let iconWidth: CGFloat = 32
#endif
/// Spacing between icon and text. /// Spacing between icon and text.
private let iconTextSpacing: CGFloat = 12 private let iconTextSpacing: CGFloat = 12

View File

@@ -170,6 +170,16 @@ struct MediaSourcesView: View {
@ViewBuilder @ViewBuilder
private func sectionHeader(_ title: String) -> some View { private func sectionHeader(_ title: String) -> some View {
#if os(macOS)
Text(title)
.font(.subheadline)
.textCase(.uppercase)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
#else
Text(title) Text(title)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -177,6 +187,7 @@ struct MediaSourcesView: View {
.padding(.horizontal, listStyle == .inset ? 32 : 16) .padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16) .padding(.top, 16)
.padding(.bottom, 8) .padding(.bottom, 8)
#endif
} }
private var sourcesList: some View { private var sourcesList: some View {
@@ -333,6 +344,16 @@ struct MediaSourcesView: View {
@ViewBuilder @ViewBuilder
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View { private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(macOS)
VStack(spacing: 0) {
Divider()
LazyVStack(spacing: 0) {
content()
}
Divider()
}
.padding(.bottom, 12)
#else
if listStyle == .inset { if listStyle == .inset {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
content() content()
@@ -347,6 +368,7 @@ struct MediaSourcesView: View {
} }
.padding(.bottom, 16) .padding(.bottom, 16)
} }
#endif
} }
@ViewBuilder @ViewBuilder
@@ -364,7 +386,11 @@ struct MediaSourcesView: View {
NavigationLink(value: NavigationDestination.instanceBrowse(instance)) { NavigationLink(value: NavigationDestination.instanceBrowse(instance)) {
instanceRow(instance) instanceRow(instance)
} }
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary) .foregroundStyle(.primary)
#endif
} }
#if os(tvOS) #if os(tvOS)
.modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused)) .modifier(FirstRowFocusModifier(isFirst: isFirst, focus: $firstSourceFocused))
@@ -416,12 +442,20 @@ struct MediaSourcesView: View {
} label: { } label: {
mediaSourceRow(source, needsPassword: true) mediaSourceRow(source, needsPassword: true)
} }
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary) .foregroundStyle(.primary)
#endif
} else { } else {
NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) { NavigationLink(value: NavigationDestination.mediaBrowser(source, path: "/")) {
mediaSourceRow(source, needsPassword: false) mediaSourceRow(source, needsPassword: false)
} }
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary) .foregroundStyle(.primary)
#endif
} }
} }
#if os(tvOS) #if os(tvOS)
@@ -460,15 +494,24 @@ struct MediaSourcesView: View {
#else #else
let rowSpacing: CGFloat = 12 let rowSpacing: CGFloat = 12
#endif #endif
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) { return HStack(spacing: rowSpacing) {
Image(systemName: instance.type.systemImage) Image(systemName: instance.type.systemImage)
.font(.title2) .font(iconFont)
.foregroundStyle(.tint) .foregroundStyle(.tint)
.frame(width: 32) .frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(instance.displayName) Text(instance.displayName)
.font(.headline) .font(titleFont)
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)") Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
@@ -478,10 +521,13 @@ struct MediaSourcesView: View {
Spacer() Spacer()
#if !os(macOS)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#endif
} }
.contentShape(Rectangle())
} }
private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View { private func mediaSourceRow(_ source: MediaSource, needsPassword: Bool) -> some View {
@@ -490,15 +536,24 @@ struct MediaSourcesView: View {
#else #else
let rowSpacing: CGFloat = 12 let rowSpacing: CGFloat = 12
#endif #endif
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) { return HStack(spacing: rowSpacing) {
Image(systemName: source.type.systemImage) Image(systemName: source.type.systemImage)
.font(.title2) .font(iconFont)
.foregroundStyle(.tint) .foregroundStyle(.tint)
.frame(width: 32) .frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(source.name) Text(source.name)
.font(.headline) .font(titleFont)
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text("\(source.type.displayName) - \(source.urlDisplayString)") Text("\(source.type.displayName) - \(source.urlDisplayString)")
@@ -514,10 +569,13 @@ struct MediaSourcesView: View {
Spacer() Spacer()
#if !os(macOS)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
#endif
} }
.contentShape(Rectangle())
} }
} }

View File

@@ -174,6 +174,16 @@ struct SourcesListView: View {
@ViewBuilder @ViewBuilder
private func sectionHeader(_ title: String) -> some View { private func sectionHeader(_ title: String) -> some View {
#if os(macOS)
Text(title)
.font(.subheadline)
.textCase(.uppercase)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 4)
#else
Text(title) Text(title)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -181,12 +191,23 @@ struct SourcesListView: View {
.padding(.horizontal, listStyle == .inset ? 32 : 16) .padding(.horizontal, listStyle == .inset ? 32 : 16)
.padding(.top, 16) .padding(.top, 16)
.padding(.bottom, 8) .padding(.bottom, 8)
#endif
} }
// MARK: - Section Card // MARK: - Section Card
@ViewBuilder @ViewBuilder
private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View { private func sectionCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
#if os(macOS)
VStack(spacing: 0) {
Divider()
LazyVStack(spacing: 0) {
content()
}
Divider()
}
.padding(.bottom, 12)
#else
if listStyle == .inset { if listStyle == .inset {
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
content() content()
@@ -205,6 +226,7 @@ struct SourcesListView: View {
} }
.padding(.bottom, 16) .padding(.bottom, 16)
} }
#endif
} }
// MARK: - Remote Servers Section // MARK: - Remote Servers Section
@@ -244,7 +266,11 @@ struct SourcesListView: View {
} label: { } label: {
instanceRow(instance) instanceRow(instance)
} }
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary) .foregroundStyle(.primary)
#endif
} }
.swipeActions { .swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
@@ -280,16 +306,25 @@ struct SourcesListView: View {
#else #else
let rowSpacing: CGFloat = 12 let rowSpacing: CGFloat = 12
#endif #endif
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) { return HStack(spacing: rowSpacing) {
Image(systemName: instance.type.systemImage) Image(systemName: instance.type.systemImage)
.font(.title2) .font(iconFont)
.foregroundStyle(.tint) .foregroundStyle(.tint)
.frame(width: 32) .frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack { HStack {
Text(instance.displayName) Text(instance.displayName)
.font(.headline) .font(titleFont)
.foregroundStyle(.primary) .foregroundStyle(.primary)
if !instance.isEnabled { if !instance.isEnabled {
@@ -306,10 +341,13 @@ struct SourcesListView: View {
Spacer() Spacer()
#if !os(macOS)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
#endif
} }
.contentShape(Rectangle())
} }
@ViewBuilder @ViewBuilder
@@ -380,7 +418,11 @@ struct SourcesListView: View {
} label: { } label: {
mediaSourceRow(source, needsPassword: needsPassword) mediaSourceRow(source, needsPassword: needsPassword)
} }
#if os(macOS)
.buttonStyle(.plain)
#else
.foregroundStyle(.primary) .foregroundStyle(.primary)
#endif
} }
.swipeActions { .swipeActions {
SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in SwipeAction(symbolImage: "pencil", tint: .white, background: .orange) { reset in
@@ -416,16 +458,25 @@ struct SourcesListView: View {
#else #else
let rowSpacing: CGFloat = 12 let rowSpacing: CGFloat = 12
#endif #endif
#if os(macOS)
let iconFont: Font = .title3
let iconFrameWidth: CGFloat = 24
let titleFont: Font = .body
#else
let iconFont: Font = .title2
let iconFrameWidth: CGFloat = 32
let titleFont: Font = .headline
#endif
return HStack(spacing: rowSpacing) { return HStack(spacing: rowSpacing) {
Image(systemName: source.type.systemImage) Image(systemName: source.type.systemImage)
.font(.title2) .font(iconFont)
.foregroundStyle(.tint) .foregroundStyle(.tint)
.frame(width: 32) .frame(width: iconFrameWidth)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
HStack { HStack {
Text(source.name) Text(source.name)
.font(.headline) .font(titleFont)
.foregroundStyle(.primary) .foregroundStyle(.primary)
if !source.isEnabled { if !source.isEnabled {
@@ -446,10 +497,13 @@ struct SourcesListView: View {
Spacer() Spacer()
#if !os(macOS)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.caption)
.foregroundStyle(.tertiary) .foregroundStyle(.tertiary)
#endif
} }
.contentShape(Rectangle())
} }
// MARK: - Disabled Badge // MARK: - Disabled Badge