217 lines
6.2 KiB
Swift
217 lines
6.2 KiB
Swift
|
import SwiftUI
|
|||
|
import WMF
|
|||
|
|
|||
|
private struct SparklineShape: Shape {
|
|||
|
|
|||
|
// MARK: Private Properties
|
|||
|
|
|||
|
private let data: [CGFloat]
|
|||
|
|
|||
|
// MARK: Public
|
|||
|
|
|||
|
init(data: [NSNumber]?) {
|
|||
|
self.data = data?.compactMap { CGFloat($0.doubleValue) } ?? []
|
|||
|
}
|
|||
|
|
|||
|
// MARK: Shape
|
|||
|
|
|||
|
func path(in rect: CGRect) -> Path {
|
|||
|
var path = Path()
|
|||
|
|
|||
|
guard data.count > 1, let min = data.min(), let max = data.max() else {
|
|||
|
return path
|
|||
|
}
|
|||
|
|
|||
|
guard min != max else {
|
|||
|
path.move(to: CGPoint(x: rect.minX, y: rect.midY))
|
|||
|
path.addLine(to: CGPoint(x: rect.maxX, y: rect.midY))
|
|||
|
return path
|
|||
|
}
|
|||
|
|
|||
|
let minY = rect.minY
|
|||
|
let width = rect.width / CGFloat(data.count - 1)
|
|||
|
let height = rect.maxY - rect.minY
|
|||
|
|
|||
|
var points: [CGPoint] = []
|
|||
|
|
|||
|
for (index, dataPoint) in data.enumerated() {
|
|||
|
let relativeY = dataPoint - CGFloat(min)
|
|||
|
let normalizedY = 1 - relativeY/(CGFloat(max-min))
|
|||
|
let y = minY + height * normalizedY
|
|||
|
let x = width * CGFloat(index)
|
|||
|
points.append(CGPoint(x: x, y: y))
|
|||
|
}
|
|||
|
|
|||
|
path.move(to: points[0])
|
|||
|
for (index, point) in points[1...points.count-1].enumerated() {
|
|||
|
let fromPoint = points[index]
|
|||
|
let midPoint = CGPoint.midPointFrom(fromPoint, to: point)
|
|||
|
let midPointControlPoint = CGPoint.quadCurveControlPointFrom(midPoint, to: fromPoint)
|
|||
|
path.addQuadCurve(to: midPoint, control: midPointControlPoint)
|
|||
|
let toPointControlPoint = CGPoint.quadCurveControlPointFrom(midPoint, to: point)
|
|||
|
path.addQuadCurve(to: point, control: toPointControlPoint)
|
|||
|
}
|
|||
|
|
|||
|
return path
|
|||
|
}
|
|||
|
|
|||
|
}
|
|||
|
|
|||
|
struct SparklineGrid: View {
|
|||
|
@Environment(\.colorScheme) private var colorScheme
|
|||
|
|
|||
|
// MARK: Properties
|
|||
|
|
|||
|
var gridStyle: Sparkline.GridStyle
|
|||
|
|
|||
|
// MARK: View
|
|||
|
|
|||
|
var body: some View {
|
|||
|
switch gridStyle {
|
|||
|
case .horizontal:
|
|||
|
GeometryReader { proxy in
|
|||
|
let yOffset = proxy.size.height / 2.0
|
|||
|
|
|||
|
Path { path in
|
|||
|
path.move(to: CGPoint(x: 0, y: yOffset))
|
|||
|
path.addLine(to: CGPoint(x: proxy.size.width, y: yOffset))
|
|||
|
|
|||
|
path.move(to: CGPoint(x: 0, y: yOffset / 2.0))
|
|||
|
path.addLine(to: CGPoint(x: proxy.size.width, y: yOffset / 2.0))
|
|||
|
|
|||
|
path.move(to: CGPoint(x: 0, y: yOffset * 1.5))
|
|||
|
path.addLine(to: CGPoint(x: proxy.size.width, y: yOffset * 1.5))
|
|||
|
}
|
|||
|
.stroke(style: StrokeStyle(lineWidth: 1.0, lineCap: .round))
|
|||
|
}
|
|||
|
case .horizontalAndVertical:
|
|||
|
ZStack {
|
|||
|
VStack {
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(height: 1)
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(height: 1)
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(height: 1)
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(height: 1)
|
|||
|
Spacer()
|
|||
|
}
|
|||
|
HStack {
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(width: 1)
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(width: 1)
|
|||
|
Spacer()
|
|||
|
Rectangle().frame(width: 1)
|
|||
|
Spacer()
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
struct Sparkline: View {
|
|||
|
@Environment(\.colorScheme) private var colorScheme
|
|||
|
|
|||
|
// MARK: Nested Types
|
|||
|
|
|||
|
enum Style {
|
|||
|
case compact
|
|||
|
case compactWithViewCount
|
|||
|
case expanded
|
|||
|
}
|
|||
|
|
|||
|
enum GridStyle {
|
|||
|
case horizontal
|
|||
|
case horizontalAndVertical
|
|||
|
}
|
|||
|
|
|||
|
// MARK: Properties
|
|||
|
|
|||
|
var style: Style = .compact
|
|||
|
var gridStyle: GridStyle = .horizontal
|
|||
|
var lineWidth: CGFloat {
|
|||
|
return style == .expanded ? 2.25 : 1.5
|
|||
|
}
|
|||
|
|
|||
|
var timeSeries: [NSNumber]? = []
|
|||
|
var containerBackgroundColor: Color {
|
|||
|
switch style {
|
|||
|
case .compactWithViewCount:
|
|||
|
return colorScheme == .dark ? Color.white.opacity(0.12) : Color(red: 248/255.0, green: 248/255.0, blue: 250/255.0, opacity: 1)
|
|||
|
case .compact:
|
|||
|
return colorScheme == .dark ? .black : .white
|
|||
|
case .expanded:
|
|||
|
return colorScheme == .dark ? .black : .white
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
var gradientStartColor: Color {
|
|||
|
colorScheme == .light
|
|||
|
? Theme.light.colors.rankGradientStart.asColor
|
|||
|
: Theme.dark.colors.rankGradientStart.asColor
|
|||
|
}
|
|||
|
|
|||
|
var gradientEndColor: Color {
|
|||
|
colorScheme == .light
|
|||
|
? Theme.light.colors.rankGradientEnd.asColor
|
|||
|
: Theme.dark.colors.rankGradientEnd.asColor
|
|||
|
}
|
|||
|
|
|||
|
// MARK: View
|
|||
|
|
|||
|
var body: some View {
|
|||
|
if style == .compact || style == .compactWithViewCount {
|
|||
|
HStack {
|
|||
|
Spacer().frame(width: 4)
|
|||
|
ZStack {
|
|||
|
SparklineGrid(gridStyle: .horizontal)
|
|||
|
.foregroundColor(colorScheme == .dark
|
|||
|
? Color(.sRGB, red: 55/255.0, green: 55/255.0, blue: 55/255.0, opacity: 1)
|
|||
|
: Color(.sRGB, red: 235/255.0, green: 235/255.0, blue: 235/255.0, opacity: 1)
|
|||
|
)
|
|||
|
.layoutPriority(-1)
|
|||
|
SparklineShape(data: timeSeries)
|
|||
|
.stroke(
|
|||
|
LinearGradient(gradient: Gradient(colors: [gradientStartColor, gradientEndColor]), startPoint: /*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/, endPoint: /*@START_MENU_TOKEN@*/.trailing/*@END_MENU_TOKEN@*/), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
|
|||
|
.frame(width: style == .compact ? 22 : 30, alignment: .leading)
|
|||
|
.padding([.top, .bottom, .leading, .trailing], 3)
|
|||
|
}
|
|||
|
if style == .compactWithViewCount {
|
|||
|
Text("\(currentViewCountOrEmpty)")
|
|||
|
.font(.system(size: 12))
|
|||
|
.fontWeight(.medium)
|
|||
|
.foregroundColor(Theme.light.colors.rankGradientEnd.asColor)
|
|||
|
}
|
|||
|
Spacer().frame(width: 4)
|
|||
|
}
|
|||
|
.background(containerBackgroundColor)
|
|||
|
} else {
|
|||
|
ZStack {
|
|||
|
SparklineGrid(gridStyle: .horizontalAndVertical)
|
|||
|
.foregroundColor(colorScheme == .dark
|
|||
|
? Color(.sRGB, red: 55/255.0, green: 55/255.0, blue: 55/255.0, opacity: 1)
|
|||
|
: Color(.sRGB, red: 235/255.0, green: 235/255.0, blue: 235/255.0, opacity: 1)
|
|||
|
)
|
|||
|
SparklineShape(data: timeSeries)
|
|||
|
.stroke(
|
|||
|
LinearGradient(gradient: Gradient(colors: [gradientStartColor, gradientEndColor]), startPoint: /*@START_MENU_TOKEN@*/.leading/*@END_MENU_TOKEN@*/, endPoint: /*@START_MENU_TOKEN@*/.trailing/*@END_MENU_TOKEN@*/), style: StrokeStyle(lineWidth: lineWidth, lineCap: .round, lineJoin: .round))
|
|||
|
.padding([.top, .bottom, .leading, .trailing], 8)
|
|||
|
}
|
|||
|
.background(containerBackgroundColor)
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// MARK: Private
|
|||
|
|
|||
|
private var currentViewCountOrEmpty: String {
|
|||
|
guard let currentViewCount = timeSeries?.last else {
|
|||
|
return "–"
|
|||
|
}
|
|||
|
|
|||
|
return NumberFormatter.localizedThousandsStringFromNumber(currentViewCount)
|
|||
|
}
|
|||
|
|
|||
|
}
|