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()
}