Skip to main content

Adaptives Dashboard mit Widgets

Beschreibung

Diese SwiftUI-View bietet ein adaptives Dashboard, auf dem Nutzer verschiedene Widgets hinzufügen, entfernen und in der Größe anpassen können. Das Layout passt sich automatisch der aktuellen Widget-Konfiguration an und sorgt so stets für eine übersichtliche und organisierte Darstellung relevanter Echtzeitdaten.

🔍 Zweck

  • Personalisierte Startseiten für Wetter-, Nachrichten- oder Kalenderinformationen
  • Übersichtsseiten in Smart Home Apps zur Anzeige verschiedener Steuerungswidgets
  • Finanz-Dashboards mit individuell konfigurierbaren Kontoständen und Diagrammen
  • Sport-Dashboards mit Live-Scores und News aus unterschiedlichen Quellen
  • Monitoring-Lösungen zur Visualisierung wichtiger Systemdaten auf einen Blick

🖥️ Betriebssystem

iOS

📄 Codebeispiel

import SwiftUI
internal import Combine

// MARK: - Widget Model & Types

enum DashboardWidgetType: String, CaseIterable, Identifiable {
    case weather = "Weather"
    case news = "News"
    
    var id: String { rawValue }
}

enum WidgetSizeOption: String, CaseIterable, Identifiable {
    case small, medium, large
    
    var id: String { rawValue }
    
    var columns: Int {
        switch self {
        case .small: return 1
        case .medium: return 2
        case .large: return 3
        }
    }
}

struct DashboardWidgetConfig: Identifiable, Equatable, Hashable {
    let id: UUID
    var type: DashboardWidgetType
    var size: WidgetSizeOption
    
    init(type: DashboardWidgetType, size: WidgetSizeOption) {
        self.id = UUID()
        self.type = type
        self.size = size
    }
}

// MARK: - Dashboard Data Model

class AdaptiveDashboardModel: ObservableObject {
    
    @Published var widgets: [DashboardWidgetConfig] = [
        DashboardWidgetConfig(type: .weather, size: .medium),
        DashboardWidgetConfig(type: .news, size: .small)
    ]
}

// MARK: - Individual Widgets (Sample Data)

struct WeatherWidgetView: View {
    let size: WidgetSizeOption
    
    var body: some View {
        VStack(alignment: .leading, spacing: size == .small ? 4 : 10) {
            Label("Wetter", systemImage: "cloud.sun.fill")
                .font(size == .small ? .subheadline : (size == .medium ? .headline : .title2))
                .foregroundStyle(.blue)
            
            HStack(alignment: .lastTextBaseline) {
                Image(systemName: "thermometer.sun.fill")
                    .imageScale(.large)
                Text("21° C")
                    .font(size == .large ? .title2 : .headline).bold()
                
                if size != .small {
                    Spacer()
                    Text("Bewölkt").font(.subheadline).foregroundColor(.secondary)
                }
            }

            if size == .large {
                HStack {
                    Label("Min", systemImage: "arrow.down").font(.caption2).foregroundStyle(.secondary)
                    Text("14°")
                    Label("Max", systemImage: "arrow.up").font(.caption2).foregroundStyle(.secondary)
                    Text("23°")
                }.padding(.top, -2)
                HStack {
                    Label("Regen", systemImage: "drop.fill").font(.caption2).foregroundStyle(.teal)
                    Text("15%")
                }.padding(.top, -6)
            }
            
            Spacer(minLength: size == .large ? 12 : 0)
            
        }
        .padding()
        .background(.blue.opacity(0.08))
        .clipShape(RoundedRectangle(cornerRadius: size == .large ? 28 : 18))
        .frame(
            minWidth: CGFloat(size.columns * 100),
            maxWidth: .infinity, minHeight: CGFloat(size.columns * 60),
            maxHeight: .infinity
        )
        // .shadow(radius: size == .large ? 5 : .zero) // Uncomment for visual shadow effect!
        // .border(Color.blue, width: size == .large ? 2 : .zero)
        // .animation(nil, value: size)
        // .id(size)
        // .transition(.scale.combined(with: .opacity))
        
        // Accessibility label for better description:
        .accessibilityLabel(Text("Weather widget with \(size.rawValue) size"))
    }
}

struct NewsWidgetView: View {
    let size: WidgetSizeOption
    
    var headlines: [String] {
        [
            "SwiftUI revolutioniert App Entwicklung",
            "Apple veröffentlicht neues iOS Update",
            "Widgets werden immer beliebter"
        ]
    }

    var body: some View {
        VStack(alignment: .leading) {
            Label("News", systemImage: "newspaper.fill")
                .font(size == .small ? .subheadline : .headline)
                .foregroundStyle(.orange)

            ForEach(headlines.prefix(size == .small ? 1 : (size == .medium ? 2 : 3)), id: \.self) { headline in
                HStack(alignment: .top) {
                    Circle().fill(.orange).frame(width: size == .small ? 7 : 10, height: size == .small ? 7 : 10)
                    Text(headline).font(size == .small ? .caption : .callout).lineLimit(1).truncationMode(.tail)
                }.padding(.vertical, size == .small ? 1 : 0)
            }

            Spacer(minLength: size == .large ? 16 : 0)
            
        }
        .padding()
        .background(.orange.opacity(0.09))
        .clipShape(RoundedRectangle(cornerRadius: size == .large ? 22 : 13))
        .frame(
            minWidth: CGFloat(size.columns * 110),
            maxWidth: .infinity,
            minHeight: CGFloat(size.columns * 44),
            maxHeight: .infinity
        )
        // .border(Color.orange, width: size == .large ? 2 : .zero)
        // .shadow(radius: size == .large ? 5 : .zero)
        
        // Accessibility description for headline count:
        .accessibilityLabel(Text("\(headlines.prefix(size == .small ? 1 : (size == .medium ? 2 : 3)).count) news headlines"))
    }
}

// Helper for CGFloat conversion from Int (for concise layout expressions):
fileprivate extension Int {
    var cgf: CGFloat { CGFloat(self) }
}

// MARK: - Adaptive Grid Dashboard Main View

struct AdaptiveDashboardView: View {
    @StateObject private var dashboardModel = AdaptiveDashboardModel()
    @State private var showAddWidgetSheet = false
    
    private let gridSpacing: CGFloat = 14
    private let maxGridColumns = 3

    // Returns a two-dimensional array of widgets arranged into rows respecting their column span.
    private func arrangedWidgets() -> [[DashboardWidgetConfig]] {
        var result: [[DashboardWidgetConfig]] = []
        var row: [DashboardWidgetConfig] = []
        var columnsUsed = 0
        
        for widget in dashboardModel.widgets {
            if columnsUsed + widget.size.columns > maxGridColumns {
                result.append(row)
                row = []
                columnsUsed = 0
            }
            row.append(widget)
            columnsUsed += widget.size.columns
        }
        if !row.isEmpty {
            result.append(row)
        }
        return result
    }
    
    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVStack(spacing: gridSpacing) {
                    ForEach(arrangedWidgets(), id: \.self) { rowWidgets in
                        AdaptiveDashboardRowView(
                            rowWidgets: rowWidgets,
                            updateWidget: updateWidget,
                            removeWidget: removeWidget,
                            selectedIcon: selectedIcon
                        )
                    }
                    if dashboardModel.widgets.count < 8 {
                        Button {
                            showAddWidgetSheet = true
                        } label: {
                            Label("Add Widget", systemImage: "plus.circle.fill")
                                .frame(maxWidth: .infinity, minHeight: CGFloat(maxGridColumns * 32))
                                .background(
                                    RoundedRectangle(cornerRadius: CGFloat(maxGridColumns * 8))
                                        .strokeBorder(style: StrokeStyle(lineWidth: 1))
                                )
                        }.padding([.top, .bottom], 6)
                    }
                } // LazyVStack End
            }.padding()
            
            // Add Widget Sheet/Popover:
            .sheet(isPresented: $showAddWidgetSheet) {
                AddWidgetSheet(existingTypes: Set(dashboardModel.widgets.map { $0.type })) { type, size in
                    dashboardModel.widgets.append(DashboardWidgetConfig(type: type, size: size))
                }
            }
            
            // Navigation Title:
            .navigationTitle("Mein Dashboard")
        } // NavigationStack End
    }
    
    func updateWidget(_ id: UUID, _ mutate: (inout DashboardWidgetConfig) -> Void) {
        guard let idx = dashboardModel.widgets.firstIndex(where: { $0.id == id }) else { return }
        mutate(&dashboardModel.widgets[idx])
    }
    
    func removeWidget(_ id: UUID) {
        dashboardModel.widgets.removeAll(where: { $0.id == id })
    }
    
    func selectedIcon(for sizeOption: WidgetSizeOption) -> String {
        switch sizeOption {
        case .small: return "square.grid.1x1"
        case .medium: return "rectangle.grid.2x2"
        case .large: return "rectangle.grid.3x2"
        }
    }
}

struct AdaptiveDashboardRowView: View {
    let rowWidgets: [DashboardWidgetConfig]
    let updateWidget: (UUID, (inout DashboardWidgetConfig) -> Void) -> Void
    let removeWidget: (UUID) -> Void
    let selectedIcon: (WidgetSizeOption) -> String

    var body: some View {
        HStack(spacing: 14) {
            ForEach(rowWidgets) { widget in
                let content: AnyView = {
                    switch widget.type {
                    case .weather:
                        return AnyView(WeatherWidgetView(size: widget.size))
                    case .news:
                        return AnyView(NewsWidgetView(size: widget.size))
                    }
                }()
                content.contextMenu {
                    ForEach(WidgetSizeOption.allCases, id: \.self) { newSize in
                        Button(action: {
                            updateWidget(widget.id) { $0.size = newSize }
                        }) {
                            Label(newSize.rawValue.capitalized, systemImage: selectedIcon(newSize))
                        }.disabled(widget.size == newSize)
                    }
                    Divider()
                    Button(role: .destructive) {
                        removeWidget(widget.id)
                    } label: {
                        Label("Remove", systemImage: "trash")
                    }
                }
            }
        }
    }
}

// Sheet for adding new widgets with type/size selection (avoids duplicates!)
struct AddWidgetSheet: View {
    let existingTypes: Set<DashboardWidgetType>
    @Environment(\.dismiss) private var dismiss
    @State private var selectedType = DashboardWidgetType.weather
    @State private var selectedSize = WidgetSizeOption.medium
    let onAdd: (DashboardWidgetType, WidgetSizeOption) -> Void

    var availableTypes: [DashboardWidgetType] {
        DashboardWidgetType.allCases.filter { !existingTypes.contains($0) }
    }

    var body: some View {
        NavigationStack {
            Form {
                Picker("Typ", selection: $selectedType) {
                    ForEach(availableTypes) { type in
                        Text(type.rawValue).tag(type)
                    }
                }.pickerStyle(MenuPickerStyle())
                
                Picker("Größe", selection: $selectedSize) {
                    ForEach(WidgetSizeOption.allCases) { opt in
                        Text(opt.rawValue.capitalized).tag(opt)
                    }
                }.pickerStyle(SegmentedPickerStyle())
                
                Button(action: {
                    onAdd(selectedType, selectedSize)
                    dismiss()
                }) {
                    Label("Hinzufügen", systemImage: "plus.app.fill")
                        .frame(maxWidth: .infinity, minHeight: 44)
                }.disabled(!availableTypes.contains(selectedType))
            }.navigationTitle(Text("Neues Widget"))
        } // NavigationStack End
    }
}

// MARK:- Preview (Modern Syntax!)
#Preview {
    AdaptiveDashboardView()
}