207 lines
6.8 KiB
Swift
207 lines
6.8 KiB
Swift
import SwiftUI
|
||
|
||
// MARK: - 通用计量器:根据 GaugeDisplayStyle 动态渲染
|
||
|
||
struct StyledGauge: View {
|
||
let title: String
|
||
let icon: String
|
||
let value: Double
|
||
let unit: String
|
||
let detail: String
|
||
let style: GaugeDisplayStyle
|
||
let fontSize: Double
|
||
var maxValue: Double = 100
|
||
var animateGraphic: Bool = true
|
||
var animateText: Bool = true
|
||
|
||
private var progress: Double {
|
||
maxValue > 0 ? min(value / maxValue, 1.0) : 0
|
||
}
|
||
|
||
private var gaugeColor: Color {
|
||
if progress < 0.4 { return .green }
|
||
if progress < 0.7 { return .yellow }
|
||
if progress < 0.85 { return .orange }
|
||
return .red
|
||
}
|
||
|
||
/// 文字动画:条件应用 contentTransition
|
||
@ViewBuilder
|
||
private func animatedNumber(_ text: Text) -> some View {
|
||
if animateText {
|
||
text.contentTransition(.numericText(value: value))
|
||
} else {
|
||
text
|
||
}
|
||
}
|
||
|
||
private var graphicAnim: Animation? {
|
||
animateGraphic ? .easeOut(duration: 0.2) : nil
|
||
}
|
||
|
||
private var textAnim: Animation? {
|
||
animateText ? .easeOut(duration: 0.3) : nil
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: icon)
|
||
.font(.system(size: fontSize))
|
||
.foregroundColor(.secondary)
|
||
Text(title)
|
||
.font(.system(size: fontSize, weight: .medium))
|
||
.foregroundColor(.secondary)
|
||
Spacer()
|
||
}
|
||
|
||
switch style {
|
||
case .bar: barView
|
||
case .ring: ringView
|
||
case .text: textView
|
||
case .pie: pieView
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 条状百分比
|
||
|
||
private var barView: some View {
|
||
VStack(spacing: 2) {
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
Capsule()
|
||
.fill(Color.white.opacity(0.12))
|
||
.frame(height: 8)
|
||
Capsule()
|
||
.fill(LinearGradient(
|
||
colors: [gaugeColor.opacity(0.8), gaugeColor],
|
||
startPoint: .leading, endPoint: .trailing))
|
||
.frame(width: max(geo.size.width * CGFloat(progress), 4), height: 8)
|
||
.animation(graphicAnim, value: value)
|
||
}
|
||
}
|
||
.frame(height: 8)
|
||
|
||
HStack {
|
||
animatedNumber(Text(String(format: "%.1f%@", value, unit))
|
||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||
.foregroundColor(.primary))
|
||
Spacer()
|
||
Text(detail)
|
||
.font(.system(size: fontSize - 2))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.animation(textAnim, value: value)
|
||
}
|
||
}
|
||
|
||
// MARK: - 圆环进度
|
||
|
||
private var ringView: some View {
|
||
HStack(spacing: 10) {
|
||
ZStack {
|
||
Circle()
|
||
.stroke(Color.white.opacity(0.12), lineWidth: 5)
|
||
.frame(width: 48, height: 48)
|
||
Circle()
|
||
.trim(from: 0, to: CGFloat(progress))
|
||
.stroke(AngularGradient(
|
||
colors: [gaugeColor.opacity(0.7), gaugeColor, gaugeColor.opacity(0.9)],
|
||
center: .center),
|
||
style: StrokeStyle(lineWidth: 5, lineCap: .round))
|
||
.rotationEffect(.degrees(-90))
|
||
.frame(width: 48, height: 48)
|
||
.animation(graphicAnim, value: value)
|
||
|
||
animatedNumber(Text(String(format: "%.0f", value))
|
||
.font(.system(size: fontSize - 1, weight: .bold, design: .monospaced))
|
||
.foregroundColor(.primary))
|
||
}
|
||
.animation(textAnim, value: value)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
animatedNumber(Text(String(format: "%.1f%@", value, unit))
|
||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||
.foregroundColor(.primary))
|
||
Text(detail)
|
||
.font(.system(size: fontSize - 2))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.animation(textAnim, value: value)
|
||
}
|
||
}
|
||
|
||
// MARK: - 纯文本
|
||
|
||
private var textView: some View {
|
||
HStack {
|
||
animatedNumber(Text(String(format: "%.1f%@", value, unit))
|
||
.font(.system(size: fontSize + 6, weight: .bold, design: .monospaced))
|
||
.foregroundColor(gaugeColor))
|
||
.animation(textAnim, value: value)
|
||
Spacer()
|
||
Text(detail)
|
||
.font(.system(size: fontSize - 2))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
|
||
// MARK: - 扇形圆盘
|
||
|
||
private var pieView: some View {
|
||
HStack(spacing: 10) {
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.white.opacity(0.05))
|
||
.frame(width: 44, height: 44)
|
||
|
||
PieSlice(endAngle: .degrees(360 * progress))
|
||
.fill(gaugeColor.opacity(0.85))
|
||
.frame(width: 42, height: 42)
|
||
.clipShape(Circle())
|
||
.animation(graphicAnim, value: value)
|
||
|
||
animatedNumber(Text(String(format: "%.0f", value))
|
||
.font(.system(size: fontSize - 2, weight: .bold, design: .monospaced))
|
||
.foregroundColor(.white))
|
||
}
|
||
.animation(textAnim, value: value)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
animatedNumber(Text(String(format: "%.1f%@", value, unit))
|
||
.font(.system(size: fontSize + 2, weight: .bold, design: .monospaced))
|
||
.foregroundColor(.primary))
|
||
Text(detail)
|
||
.font(.system(size: fontSize - 2))
|
||
.foregroundColor(.secondary)
|
||
.lineLimit(1)
|
||
}
|
||
.animation(textAnim, value: value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 扇形 Shape
|
||
|
||
struct PieSlice: Shape {
|
||
var endAngle: Angle
|
||
|
||
func path(in rect: CGRect) -> Path {
|
||
var path = Path()
|
||
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||
let radius = min(rect.width, rect.height) / 2
|
||
|
||
path.move(to: center)
|
||
path.addArc(center: center, radius: radius,
|
||
startAngle: .degrees(-90),
|
||
endAngle: endAngle - .degrees(90),
|
||
clockwise: false)
|
||
path.closeSubpath()
|
||
return path
|
||
}
|
||
}
|