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

// 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 {
    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:(size.columns*100).cgf,minHeight:(size.columns*60).cgf,maxWidth:.infinity,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)// Disable unwanted animation on resize.
          //.id(size)// Keyed by 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 :.zero)
         }

         Spacer(minLength:size==.large ?16 :.zero)

     }.padding().background(.orange.opacity(0.09)).clipShape(RoundedRectangle(cornerRadius:size==.large ?22 :13))
      .frame(minWidth:(size.columns*110).cgf,minHeight:(size.columns*44).cgf,maxWidth:.infinity,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
   
   // Maximum columns supported by layout/grid:
   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
               HStack(spacing:gridSpacing){
                   ForEach(rowWidgets){widget in
                       widgetBody(widgetConfig:widget)
                           // Context menu for resizing/removing 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{
               Button{
                   showAddWidgetSheet=true;
               }label:{
                   Label("Add Widget",systemImage:"plus.circle.fill")
                       .frame(maxWidth:.infinity,minHeight:(maxGridColumns*32).cgf)
                       .background(RoundedRectangle(cornerRadius:maxGridColumns*8).strokeBorder(style:.init(lineWidth:.one)))
               }.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))
             }
         }

         //.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,_ mutate:(inout DashboardWidgetConfig)->Void){
      guard let idx=dashboardModel.widgets.firstIndex(where:{ $0.id == id }) else{return}
      mutate(&dashboardModel.widgets[idx])
   }

   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){
       dashboardModel.widgets.removeAll(where:{ $0.id == id })
   }

   func selectedIcon(for sizeOption : WidgetSizeOption)->String{
      switch sizeOption{
          case.small:"square.grid.1x1"
          case.medium:"rectangle.grid.2x2"
          case.large:"rectangle.grid.3x2"
      }
   }

}

// 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 as DashboardWidgetType?)
                     } 
                 }.pickerStyle(MenuPickerStyle())
                 
                 Picker("Größe",selection:$selectedSize){
                     ForEach(WidgetSizeOption.allCases){opt in 
                         Text(opt.rawValue.capitalized).tag(opt as WidgetSizeOption?)
                     } 
                 }.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{
 AdaptiveDashboardView()
}