129 lines
4.5 KiB
Swift
129 lines
4.5 KiB
Swift
|
|
import SwiftUI
|
|||
|
|
|
|||
|
|
struct HealthRing: View {
|
|||
|
|
let progress: Double // 0.0 – 1.0
|
|||
|
|
let color: Color
|
|||
|
|
var lineWidth: CGFloat = 12
|
|||
|
|
var size: CGFloat = 120
|
|||
|
|
var label: String? = nil
|
|||
|
|
|
|||
|
|
@State private var animatedProgress: Double = 0
|
|||
|
|
|
|||
|
|
private var clampedProgress: Double { min(max(progress, 0), 1) }
|
|||
|
|
private var percentText: String { "\(Int((animatedProgress * 100).rounded()))" }
|
|||
|
|
|
|||
|
|
var body: some View {
|
|||
|
|
ZStack {
|
|||
|
|
Circle()
|
|||
|
|
.stroke(color.opacity(0.15), lineWidth: lineWidth)
|
|||
|
|
|
|||
|
|
Circle()
|
|||
|
|
.trim(from: 0, to: animatedProgress)
|
|||
|
|
.stroke(
|
|||
|
|
color,
|
|||
|
|
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
|||
|
|
)
|
|||
|
|
.rotationEffect(.degrees(-90))
|
|||
|
|
.animation(
|
|||
|
|
.spring(response: 0.85, dampingFraction: 0.72),
|
|||
|
|
value: animatedProgress
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
VStack(spacing: 2) {
|
|||
|
|
HStack(alignment: .firstTextBaseline, spacing: 1) {
|
|||
|
|
Text(percentText)
|
|||
|
|
.font(.system(size: size * 0.22, weight: .bold, design: .rounded))
|
|||
|
|
.foregroundStyle(.primary)
|
|||
|
|
.contentTransition(.numericText(countsDown: animatedProgress < clampedProgress))
|
|||
|
|
.animation(
|
|||
|
|
.spring(response: 0.85, dampingFraction: 0.72),
|
|||
|
|
value: animatedProgress
|
|||
|
|
)
|
|||
|
|
Text("%")
|
|||
|
|
.font(.system(size: size * 0.13, weight: .semibold, design: .rounded))
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
}
|
|||
|
|
if let label {
|
|||
|
|
Text(label)
|
|||
|
|
.font(.system(size: size * 0.1, weight: .medium))
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
.lineLimit(1)
|
|||
|
|
.minimumScaleFactor(0.5)
|
|||
|
|
.frame(maxWidth: size * 0.55)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
.frame(width: size, height: size)
|
|||
|
|
.onAppear {
|
|||
|
|
animatedProgress = clampedProgress
|
|||
|
|
}
|
|||
|
|
.onChange(of: progress) { _, newValue in
|
|||
|
|
animatedProgress = min(max(newValue, 0), 1)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
#Preview {
|
|||
|
|
@Previewable @State var cpu: Double = 0.72
|
|||
|
|
@Previewable @State var mem: Double = 0.45
|
|||
|
|
@Previewable @State var disk: Double = 0.88
|
|||
|
|
|
|||
|
|
NavigationStack {
|
|||
|
|
ScrollView {
|
|||
|
|
LazyVStack(spacing: 32) {
|
|||
|
|
HStack(spacing: 24) {
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
HealthRing(
|
|||
|
|
progress: cpu,
|
|||
|
|
color: Color(.systemBlue),
|
|||
|
|
label: "CPU"
|
|||
|
|
)
|
|||
|
|
Text("CPU")
|
|||
|
|
.font(.caption.weight(.medium))
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
}
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
HealthRing(
|
|||
|
|
progress: mem,
|
|||
|
|
color: Color(.systemGreen),
|
|||
|
|
label: "RAM"
|
|||
|
|
)
|
|||
|
|
Text("Memory")
|
|||
|
|
.font(.caption.weight(.medium))
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
}
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
HealthRing(
|
|||
|
|
progress: disk,
|
|||
|
|
color: Color(.systemOrange),
|
|||
|
|
label: "Disk"
|
|||
|
|
)
|
|||
|
|
Text("Disk")
|
|||
|
|
.font(.caption.weight(.medium))
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
HealthRing(
|
|||
|
|
progress: 0.23,
|
|||
|
|
color: Color(.systemRed),
|
|||
|
|
lineWidth: 18,
|
|||
|
|
size: 180,
|
|||
|
|
label: "Error rate"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
VStack(spacing: 8) {
|
|||
|
|
Slider(value: $cpu, in: 0...1)
|
|||
|
|
Text("CPU: \(Int(cpu * 100))%")
|
|||
|
|
.font(.caption)
|
|||
|
|
.foregroundStyle(.secondary)
|
|||
|
|
}
|
|||
|
|
.padding(.horizontal)
|
|||
|
|
}
|
|||
|
|
.padding()
|
|||
|
|
}
|
|||
|
|
.navigationTitle("Health")
|
|||
|
|
.background(Color(.systemBackground))
|
|||
|
|
}
|
|||
|
|
}
|