Zebra-Streifen für Tabellenansichten (found.md #8)

TableZebra-Helper (AppKit NSColor.alternatingContentBackgroundColors)
angewendet auf: LAN-Scanner-Geräte/Rohfelder/Port-Scan, Experte-
Eintragsliste + "Weitere Parameter"-Grid, Bekannte Router + Interface-
Liste (Verbinden), Sicherungsliste, Review/Apply-Log, Übersicht-
Knotendetails.

Root Cause für "keine Änderung erkennbar": .formStyle(.grouped) malt
einen eigenen blickdichten Hintergrund über Section-Zeilen, der
.listRowBackground verdeckt — .scrollContentBackground(.hidden) ergänzt.

Live bestätigt ("passt").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kay
2026-09-17 16:22:26 +02:00
co-authored by Claude Sonnet 5
parent 2848205660
commit e582728040
8 changed files with 67 additions and 11 deletions
@@ -1,4 +1,18 @@
import SwiftUI
import AppKit
/// Alternating row backgrounds for table-like `Form`/`Section`/`ForEach` lists (LAN-Scanner
/// device rows, Experte-tab item lists) per explicit readability request. Uses AppKit's own
/// `NSColor.alternatingContentBackgroundColors` (the same pair `NSTableView` uses for zebra
/// striping) rather than a hand-picked gray, so it already matches the system's row-striping
/// color and adapts to Light/Dark Mode for free.
enum TableZebra {
static func color(for index: Int) -> Color {
let pair = NSColor.alternatingContentBackgroundColors
guard pair.count > 1 else { return .clear }
return Color(nsColor: pair[index % 2])
}
}
/// Central catalog for the "Settings" window (,) every value here is read/written through
/// `@AppStorage`/`UserDefaults.standard` under the raw string keys named on each property below,
@@ -95,7 +95,7 @@ struct BackupListView: View {
} else {
Form {
Section(L10n.t("Sicherungen", appLanguage)) {
ForEach(viewModel.backups) { backup in
ForEach(Array(viewModel.backups.enumerated()), id: \.element.id) { index, backup in
HStack {
VStack(alignment: .leading) {
Text(backup.host).bold()
@@ -116,10 +116,12 @@ struct BackupListView: View {
.disabled(connectionService.credentials == nil || viewModel.isRestoring)
.help(L10n.t("Diese Sicherung auf den verbundenen Router zurückspielen — nur für exakt dasselbe Routermodell.", appLanguage))
}
.listRowBackground(TableZebra.color(for: index))
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
}
}
.safeAreaInset(edge: .bottom) {
@@ -59,7 +59,7 @@ struct DevicesView: View {
Text(L10n.t("Keine Geräte", appLanguage)).appFont(.caption).foregroundStyle(.secondary)
} else {
DeviceColumnHeader(appLanguage: appLanguage)
ForEach(group.devices) { device in
ForEach(Array(group.devices.enumerated()), id: \.element.id) { index, device in
HStack(spacing: 4) {
DeviceRow(device: device, appLanguage: appLanguage, theme: colorTheme)
Spacer()
@@ -78,6 +78,7 @@ struct DevicesView: View {
.fixedSize()
.help(L10n.t("Aktionen", appLanguage))
}
.listRowBackground(TableZebra.color(for: index))
}
}
} header: {
@@ -98,6 +99,12 @@ struct DevicesView: View {
}
}
.formStyle(.grouped)
// `.formStyle(.grouped)` paints its own opaque background over each
// Section's rows `.listRowBackground` (Zebra-Streifen below) was applied
// correctly but rendered invisible underneath it. Confirmed live ("keine
// Änderungen erkennbar"): this is the documented fix, not a bug in the color
// computation itself.
.scrollContentBackground(.hidden)
}
}
.overlay(alignment: .bottom) {
@@ -480,12 +487,14 @@ private struct RawFieldsSheet: View {
.foregroundStyle(.secondary)
ScrollView {
VStack(alignment: .leading, spacing: 4) {
ForEach(device.rawFields, id: \.key) { pair in
ForEach(Array(device.rawFields.enumerated()), id: \.element.key) { index, pair in
HStack(alignment: .top) {
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
@@ -577,6 +586,7 @@ private struct PortScanResultSheet: View {
ForEach(Array(result.entries.enumerated()), id: \.element.id) { index, entry in
if index > 0 { Divider() }
PortScanRow(entry: entry, appLanguage: appLanguage, theme: theme)
.background(TableZebra.color(for: index))
}
}
}
@@ -39,7 +39,7 @@ struct ExpertMenuDetailView: View {
// `.sheet(item:)` dismissal already forces a remount of this view on its own;
// delete's confirmationDialog doesn't tear this view down at all, so nothing
// was forcing SwiftUI to re-diff the Section in place.
ForEach(viewModel.items) { item in
ForEach(Array(viewModel.items.enumerated()), id: \.element.id) { index, item in
HStack {
Button {
viewModel.startEditing(item)
@@ -61,6 +61,7 @@ struct ExpertMenuDetailView: View {
}
}
.contentShape(Rectangle())
.listRowBackground(TableZebra.color(for: index))
}
}
} header: {
@@ -75,6 +76,9 @@ struct ExpertMenuDetailView: View {
.id(viewModel.items.count)
}
.formStyle(.grouped)
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
.scrollContentBackground(.hidden)
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
@@ -171,7 +175,7 @@ struct ExpertItemEditView: View {
extraColumnHeader("Parameter")
extraColumnHeader("Wert")
}
ForEach(Array(stride(from: 0, to: viewModel.extraFields.count, by: 2)), id: \.self) { start in
ForEach(Array(stride(from: 0, to: viewModel.extraFields.count, by: 2).enumerated()), id: \.element) { rowIndex, start in
GridRow {
extraKeyField(at: start)
extraValueField(at: start)
@@ -188,6 +192,7 @@ struct ExpertItemEditView: View {
Color.clear
}
}
.background(TableZebra.color(for: rowIndex))
Divider()
.gridCellColumns(5)
}
@@ -833,12 +833,14 @@ private struct NodeDetailView: View {
if !node.detail.isEmpty {
Divider()
VStack(alignment: .leading, spacing: 4) {
ForEach(node.detail, id: \.key) { pair in
ForEach(Array(node.detail.enumerated()), id: \.element.key) { index, pair in
HStack(alignment: .top) {
Text(pair.key).appFont(.caption, design: .monospaced).foregroundStyle(.secondary)
Spacer()
Text(pair.value).appFont(.caption, design: .monospaced).multilineTextAlignment(.trailing)
}
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
@@ -847,7 +849,7 @@ private struct NodeDetailView: View {
if !connected.isEmpty {
Divider()
Text(L10n.t("Verbindungen", appLanguage)).appFont(.subheadline, bold: true)
ForEach(connected) { edge in
ForEach(Array(connected.enumerated()), id: \.element.id) { index, edge in
let otherID = edge.from == node.id ? edge.to : edge.from
let otherTitle = graph.nodes.first(where: { $0.id == otherID })?.title ?? otherID
Button {
@@ -862,6 +864,9 @@ private struct NodeDetailView: View {
}
.buttonStyle(.plain)
.help(L10n.t(OverviewStyle.explanation(for: edge.kind), appLanguage))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
.background(TableZebra.color(for: index))
}
}
}
@@ -63,6 +63,7 @@ struct ConnectView: View {
onDelete: { viewModel.removeSavedRouter(router.id) }
)
.padding(.vertical, 6)
.background(TableZebra.color(for: index))
}
}
}
@@ -351,7 +352,7 @@ struct ConnectView: View {
}
}
Section("Interfaces") {
ForEach(connectionService.interfaces) { interface in
ForEach(Array(connectionService.interfaces.enumerated()), id: \.element.id) { index, interface in
HStack {
InterfaceActivityDot(
running: interface.running,
@@ -362,10 +363,12 @@ struct ConnectView: View {
Text(interface.type).appFont(.caption).foregroundStyle(.secondary)
}
}
.listRowBackground(TableZebra.color(for: index))
}
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
} else {
ContentUnavailableView(
LocalizedStringKey(L10n.t("Nicht verbunden", appLanguage)),
@@ -10,7 +10,7 @@ struct ReviewApplyView: View {
var body: some View {
Form {
Section(L10n.t("Geplante Änderungen", appLanguage)) {
ForEach(viewModel.plannedCommands) { command in
ForEach(Array(viewModel.plannedCommands.enumerated()), id: \.element.id) { index, command in
VStack(alignment: .leading, spacing: 2) {
Text(command.summary)
if showCliDetails {
@@ -19,6 +19,7 @@ struct ReviewApplyView: View {
.foregroundStyle(.secondary)
}
}
.listRowBackground(TableZebra.color(for: index))
}
Toggle(L10n.t("Details anzeigen (RouterOS-Befehle)", appLanguage), isOn: $showCliDetails)
}
@@ -31,8 +32,9 @@ struct ReviewApplyView: View {
if !viewModel.applyLog.isEmpty {
Section(L10n.t("Ablauf", appLanguage)) {
ForEach(viewModel.applyLog, id: \.self) { line in
ForEach(Array(viewModel.applyLog.enumerated()), id: \.offset) { index, line in
Text(line).appFont(.caption)
.listRowBackground(TableZebra.color(for: index))
}
}
}
@@ -70,6 +72,7 @@ struct ReviewApplyView: View {
}
}
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht & Anwenden", appLanguage)))
.alert(
L10n.t("Anwenden fehlgeschlagen", appLanguage),
+15 -1
View File
@@ -170,6 +170,20 @@ Build+alle 99 Unit-Tests grün, live bestätigt.
**Status:** offen
### 8. Abwechselnde Farbkombis bei Tabellenansichten
**Status:** offen
**Status:** fixed (live bestätigt: "passt")
Bessere Lesbarkeit durch alternierende Zeilenfarben in Tabellenansichten (LAN-Scanner, Experte-Listen, etc.).
Umsetzung: neuer `TableZebra`-Helper (`AppPreferences.swift`) nutzt AppKits eigenes `NSColor.alternatingContentBackgroundColors` (dieselbe Farbpaar, das `NSTableView` fürs Zebra-Streifenmuster verwendet) statt einer selbst gewählten Grauabstufung — passt sich automatisch an Hell/Dunkelmodus an. Angewendet über `.listRowBackground(TableZebra.color(for: index))` auf: LAN-Scanner-Geräte-Zeilen (`DevicesView.swift`, pro Port-Gruppe neu bei Index 0 startend) und Experte-Tab-Eintragsliste (`ExpertMenuDetailView.swift`).
Bewusst nicht umgesetzt: "Weitere Parameter (frei)"-Grid im Experte-Bearbeiten-Sheet (Bug 2) — dort läuft die Zeilenzählung zweispaltig (zwei Einträge pro Grid-Zeile), Zebra-Streifen würden dort eher verwirren als helfen; bei Bedarf separat nachziehbar.
Build grün, alle 99 Unit-Tests grün.
Nachbesserung 1 (Live-Test: "keine Änderungen erkennbar") — Ursache mit Beweis gefunden statt weiter geraten: erst per `ImageRenderer`-Snapshot versucht (schlug fehl, `Form` rendert im Headless-Kontext leer — kein Ergebnis dazu), dann per Doku-Recherche bestätigt: `.formStyle(.grouped)` malt einen eigenen blickdichten Hintergrund über jede Section, der `.listRowBackground` komplett verdeckt — bekanntes, dokumentiertes SwiftUI-Verhalten, kein Fehler in der Farbberechnung. Fix: `.scrollContentBackground(.hidden)` auf beiden betroffenen `Form`s ergänzt (`DevicesView.swift`, `ExpertMenuDetailView.swift`), Standard-Kombination für genau diesen Fall.
Build grün, alle 99 Unit-Tests grün. Achtung beim Live-Test: kann auch den "Karten"-Hintergrund der übrigen Form-Bereiche (nicht nur die Zeilen) sichtbar verändern — bitte Gesamteindruck prüfen, nicht nur ob Streifen da sind.
Nachbesserung 2 (User-Feedback: auch das "Weitere Parameter"-Grid im Experte-Bearbeiten-Sheet soll Zeilen abwechselnd einfärben — Zeile 1,3,5... —, "bitte für das komplette Projekt umsetzen"): Grid-Zeilen (2 Parameter pro sichtbarer Zeile) bekommen jetzt `.background(TableZebra.color(for: rowIndex))` pro `GridRow`. Zusätzlich auf jede weitere echte Tabellen-/Listenansicht im Projekt ausgeweitet: "Bekannte Router"-Liste + Interface-Liste (Verbinden-Tab), Sicherungsliste (Sicherungen-Tab), Rohfelder-Sheet + Port-Scan-Ergebnisliste (LAN-Scanner), geplante Änderungen + Ablauf-Log (Einrichten-Wizard Review/Apply), Feld-Details + Verbindungsliste im Übersicht-Knoten-Detailpanel. Nicht angefasst: Experte-Sidebar (Navigationsliste, keine Datentabelle) und die Setup-Wizard-Konfigurationsformulare (VLAN/WLAN/LAN/WAN — je Zeile ein Mehrfeld-Unterformular, keine gleichförmigen Datenzeilen, Zebra-Streifen würden dort eher verwirren).
Build grün, alle 99 Unit-Tests grün. Bitte erneut live testen.