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