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