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 :id: String { rawValue }
}

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

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

// MARK: - Dashboard Data Model

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

// MARK: - Individual Widgets (Sample Data)

struct WeatherWidgetView :WeatherWidgetView: View {
    let size :size: WidgetSizeOption
    
    var body :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{small {
                    Spacer()
                    Text("Bewölkt").font(.subheadline).foregroundColor(.secondary)
                }
            }

            if size == .large{large HStack{{
                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{HStack {
                    Label("Regen", systemImage: "drop.fill").font(.caption2).foregroundStyle(.teal);
                    Text("15%")
                }.padding(.top, -6)
            }
            
            Spacer(minLength:size= size == .large ? 12 : 0)
            
        }
        .padding()
        .background(.blue.opacity(0.08))
        .clipShape(RoundedRectangle(cornerRadius:size= size == .large ? 28 : 18))
        .frame(
            minWidth:( CGFloat(size.columns*columns * 100).cgf,minHeight:(size.columns*60).cgf,,
            maxWidth: .infinity, minHeight: CGFloat(size.columns * 60),
            maxHeight: .infinity)infinity
        )
        // .shadow(radius:size= size == .large ? 5 : .zero) // Uncomment for visual shadow effect!
        // .border(Color.blue, width:size= size == .large ? 2 : .zero)
        // .animation(nil, value: size)
        // Disable unwanted animation on resize..id(size)
        //.id(size)// Keyed by size.
          //.transition(.scale.combined(with: .opacity))
        
        // Accessibility label for better description:
        .accessibilityLabel(Text("Weather widget with \(size.rawValue) size"))
    }
}

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

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

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

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

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

// MARK: - Adaptive Grid Dashboard Main View

struct AdaptiveDashboardView :AdaptiveDashboardView: View {
    @StateObject private var dashboardModel = AdaptiveDashboardModel()
    @State private var showAddWidgetSheet = false
    
    private let gridSpacing :gridSpacing: CGFloat = 14
   
   // Maximum columns supported by layout/grid:
    private let maxGridColumns = 3

    // Returns a two two-dimensional array of widgets arranged into rows respecting their column span.
    private func arrangedWidgets() -> [[DashboardWidgetConfig]] {
        var result: [[DashboardWidgetConfig]] = []
        var row: [DashboardWidgetConfig] = []
        var columnsUsed=columnsUsed = 0
        
        for widget in dashboardModel.widgets{widgets {
            if columnsUsed+columnsUsed + widget.size.columns > maxGridColumns{maxGridColumns {
                result.append(row);
                row=row = [];
                columnsUsed=0;columnsUsed = 0
            }
            row.append(widget);
            columnsUsed+columnsUsed += widget.size.columns;columns
        }
        if !row.isEmpty{isEmpty {
            result.append(row)
        }
        return result;result
    }
    
    var body :body: some View{View NavigationStack{{
        ScrollView{NavigationStack {
            ScrollView {
                LazyVStack(spacing: gridSpacing) {
                    ForEach(arrangedWidgets(), id: \.self) { rowWidgets in
                        HStack(spacing:gridSpacing){AdaptiveDashboardRowView(
                            ForEach(rowWidgets){widgetrowWidgets: inrowWidgets,
                            widgetBody(widgetConfig:widget)updateWidget: //updateWidget,
                            ContextremoveWidget: menuremoveWidget,
                            forselectedIcon: resizing/removingselectedIcon
                        widget:
                           .contextMenu{
                               ForEach(WidgetSizeOption.allCases,id:\.self){newSize in 
                                   Button(action:{
                                       updateWidget(widget.id){$0.size=newSize}
                                   }){
                                       Label(newSize.rawValue.capitalized,systemImage:selectedIcon(for:newSize))
                                   }.disabled(widget.size==newSize)
                               } 
                               Divider()
                               Button(role:.destructive){
                                   removeWidget(widget.id)
                               } label:{
                                   Label("Remove",systemImage:"trash")
                               }
                           }

                   } 
               }
                    }
                    if dashboardModel.widgets.count < 8{8 Button{{
                        showAddWidgetSheet=true;Button {
                            showAddWidgetSheet = true
                        } label: {
                            Label("Add Widget", systemImage: "plus.circle.fill")
                                .frame(maxWidth: .infinity, minHeight:(maxGridColumns* CGFloat(maxGridColumns * 32).cgf))
                                .background(
                                    RoundedRectangle(cornerRadius:maxGridColumns* CGFloat(maxGridColumns * 8))
                                        .strokeBorder(style:.init( StrokeStyle(lineWidth:.one) 1))
                                )
                        }.padding([.top, .bottom], 6)
                    }
                } // LazyVStack End
            }.padding()
            
            // Add Widget Sheet/Popover:
            .sheet(isPresented: $showAddWidgetSheet) {
                AddWidgetSheet(existingTypes: Set(dashboardModel.widgets.map{map { $0.type}type })) { type, size in
                    dashboardModel.widgets.append(DashboardWidgetConfig(type: type, size: size))
                }
            }

         //.toolbar{ToolbarItem(placement:.navigationBarTrailing){// ...}}
         //.safeAreaInset(edge:.bottom){...}
         //.refreshable{}
            
            // Navigation Title:
            .navigationTitle("Mein Dashboard")
        } // NavigationStack End 
   }

   @ViewBuilder func widgetBody(widgetConfig config : DashboardWidgetConfig)->some View{
       switch config.type{
         case.weather:
             WeatherWidgetView(size=config.size)

         case.news:
             NewsWidgetView(size=config.size)

       }
    }
    
    func updateWidget(_ id:UIAlertAction.Identifier, UUID, _ mutate: (inout DashboardWidgetConfig) -> Void) {
        guard let idx=idx = dashboardModel.widgets.firstIndex(where: { $0.id == id }) else{return}else mutate(&dashboardModel.widgets[idx]){ return }

   func updateWidget(_ id:NSUUID,_ mutate:(inout DashboardWidgetConfig)->Void){
       guard let idx=dashboardModel.widgets.firstIndex(where:{ $0.id == id }) else{return}
        mutate(&dashboardModel.widgets[idx])
    }
    
    func removeWidget(_ id:NSUUID) UUID) {
        dashboardModel.widgets.removeAll(where: { $0.id == id })
    }
    
    func selectedIcon(for sizeOption :sizeOption: WidgetSizeOption) ->String{ String {
        switch sizeOption{sizeOption case.{
        case .small: return "square.grid.1x1"
        case.case .medium: return "rectangle.grid.2x2"
        case.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 AddWidgetSheetAddWidgetSheet: :View View{{
    let existingTypes: Set<DashboardWidgetType>
    @Environment(\.dismiss) private var dismiss
    @State private var selectedType=selectedType = DashboardWidgetType.weather
    @State private var selectedSize=selectedSize = WidgetSizeOption.medium
    let onAdd: (DashboardWidgetType, WidgetSizeOption) -> Void

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

    var body :body: some View{View NavigationStack{{
        Form{NavigationStack {
            Form {
                Picker("Typ", selection: $selectedType) {
                    ForEach(availableTypes) { type in
                        Text(type.rawValue).tag(type as DashboardWidgetType?)type)
                    }
                }.pickerStyle(MenuPickerStyle())
                
                Picker("Größe", selection: $selectedSize) {
                    ForEach(WidgetSizeOption.allCases) { opt in
                        Text(opt.rawValue.capitalized).tag(opt as WidgetSizeOption?)opt)
                    }
                }.pickerStyle(SegmentedPickerStyle())
                
                Button(action: {
                    onAdd(selectedType, selectedSize);
                    dismiss();
                }) {
                    Label("Hinzufügen", systemImage: "plus.app.fill")
                        .frame(maxWidth: .infinity, minHeight:.one* 44)
                         //.background(.blue.opacity(0.13)).clipShape(Capsule())
                }.disabled(!availableTypes.contains(selectedType))
            }.navigationTitle(Text("Neues Widget"))
        //.toolbar{ToolbarItem(placement:.cancellationAction){Button("Abbrechen"){dismiss()}}} //.interactiveDismissDisabled(false) }//NavigationStack End
    }//body End
}

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