Skip to content

Commit 7cb91ef

Browse files
committed
Stats Insights: Add sparkline graph view to new Totals card
1 parent 8c5cd31 commit 7cb91ef

File tree

3 files changed

+145
-5
lines changed

3 files changed

+145
-5
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import UIKit
2+
import simd
3+
4+
class SparklineView: UIView {
5+
private let lineLayer = CAShapeLayer()
6+
private let maskLayer = CAShapeLayer()
7+
private let gradientLayer = CAGradientLayer()
8+
9+
private static let defaultChartColor = UIColor.muriel(name: .blue, .shade50)
10+
var chartColor: UIColor! = SparklineView.defaultChartColor {
11+
didSet {
12+
if chartColor == nil {
13+
chartColor = SparklineView.defaultChartColor
14+
}
15+
}
16+
}
17+
18+
var data: [CGFloat] = []
19+
20+
override init(frame: CGRect) {
21+
super.init(frame: frame)
22+
let initialData = [102, 109, 526, 253, 163, 227, 101].map({ CGFloat($0) })
23+
data = interpolateData(initialData)
24+
25+
initializeChart()
26+
layoutChart()
27+
}
28+
29+
required init?(coder: NSCoder) {
30+
fatalError()
31+
}
32+
33+
override func layoutSubviews() {
34+
super.layoutSubviews()
35+
36+
layoutChart()
37+
}
38+
39+
func initializeChart() {layer.isGeometryFlipped = true
40+
41+
lineLayer.strokeColor = chartColor.cgColor
42+
lineLayer.lineWidth = Constants.lineWidth
43+
lineLayer.fillColor = UIColor.clear.cgColor
44+
45+
maskLayer.strokeColor = UIColor.clear.cgColor
46+
maskLayer.fillColor = UIColor.black.cgColor
47+
48+
gradientLayer.startPoint = Constants.gradientStart
49+
gradientLayer.endPoint = Constants.gradientEnd
50+
gradientLayer.colors = [chartColor.cgColor, UIColor(white: 1.0, alpha: 0.0).cgColor]
51+
gradientLayer.mask = maskLayer
52+
gradientLayer.opacity = Constants.gradientOpacity
53+
54+
layer.addSublayer(gradientLayer)
55+
layer.addSublayer(lineLayer)
56+
}
57+
58+
private func interpolateData(_ inputData: [CGFloat]) -> [CGFloat] {
59+
guard inputData.count > 0,
60+
let first = inputData.first else {
61+
return []
62+
}
63+
64+
var interpolatedData = [first]
65+
66+
for (this, next) in zip(inputData, inputData.dropFirst()) {
67+
let interpolationIncrement = (next - this) / Constants.interpolationCount
68+
69+
for index in stride(from: 1, through: Constants.interpolationCount, by: 1) {
70+
let normalized = simd_smoothstep(this, next, this + (index * interpolationIncrement))
71+
let actual = simd_mix(this, next, normalized)
72+
73+
interpolatedData.append(actual)
74+
}
75+
}
76+
77+
return interpolatedData
78+
}
79+
80+
private func layoutChart() {
81+
CATransaction.begin()
82+
CATransaction.setDisableActions(true)
83+
84+
lineLayer.frame = bounds
85+
maskLayer.frame = bounds
86+
gradientLayer.frame = bounds
87+
88+
// Calculate points to fit along X axis, using existing interpolated Y values
89+
let segmentWidth = bounds.width / CGFloat(data.count-1)
90+
let points = data.enumerated().map({ CGPoint(x: CGFloat($0.offset) * segmentWidth, y: $0.element) })
91+
92+
// Scale Y values to fit within our bounds
93+
let maxYValue = points.map(\.y).max() ?? 1.0
94+
let scaleFactor = bounds.height / maxYValue
95+
let scaleTransform = CGAffineTransform(scaleX: 1.0, y: scaleFactor)
96+
97+
// Scale the points slightly so that the line remains within bounds, based on the line width.
98+
let xScaleFactor = (bounds.width - Constants.lineWidth) / bounds.width
99+
let yScaleFactor = (bounds.height - Constants.lineWidth) / bounds.height
100+
101+
let halfLineWidth = Constants.lineWidth / 2.0
102+
var lineTransform = CGAffineTransform(translationX: halfLineWidth, y: halfLineWidth)
103+
lineTransform = lineTransform.scaledBy(x: xScaleFactor, y: yScaleFactor)
104+
lineTransform = lineTransform.concatenating(scaleTransform)
105+
106+
// Finally, create the paths – first the line...
107+
let lineLayerPath = CGMutablePath()
108+
lineLayerPath.addLines(between: points, transform: lineTransform)
109+
110+
lineLayer.path = lineLayerPath
111+
112+
// ... then the bottom gradient
113+
if let maskLayerPath = lineLayerPath.mutableCopy() {
114+
maskLayerPath.addLine(to: CGPoint(x: bounds.width, y: 0))
115+
maskLayerPath.addLine(to: CGPoint(x: 0, y: 0))
116+
maskLayer.path = maskLayerPath
117+
}
118+
119+
CATransaction.commit()
120+
}
121+
122+
private enum Constants {
123+
static let lineWidth: CGFloat = 2.0
124+
static let gradientOpacity: Float = 0.1
125+
126+
// This number of extra data points will be interpolated in between each pair of original data points.
127+
// The higher the number, the smoother the chart line.
128+
static let interpolationCount: Double = 20
129+
130+
static let gradientStart = CGPoint(x: 0.0, y: 0.5)
131+
static let gradientEnd = CGPoint(x: 1.0, y: 0.5)
132+
}
133+
}

WordPress/Classes/ViewRelated/Stats/Insights/StatsTotalInsightsCell.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class StatsTotalInsightsCell: StatsBaseCell {
1515
private let topInnerStackView = UIStackView()
1616
private let countLabel = UILabel()
1717
private let comparisonLabel = UILabel()
18-
private let graphView = UIView()
18+
private let graphView = SparklineView()
1919

2020
// MARK: - Initialization
2121

@@ -39,7 +39,7 @@ class StatsTotalInsightsCell: StatsBaseCell {
3939
private func configureStackViews() {
4040
outerStackView.translatesAutoresizingMaskIntoConstraints = false
4141
outerStackView.axis = .vertical
42-
outerStackView.spacing = Metrics.stackViewSpacing
42+
outerStackView.spacing = Metrics.outerStackViewSpacing
4343
contentView.addSubview(outerStackView)
4444

4545
topInnerStackView.axis = .horizontal
@@ -51,7 +51,6 @@ class StatsTotalInsightsCell: StatsBaseCell {
5151

5252
private func configureGraphView() {
5353
graphView.translatesAutoresizingMaskIntoConstraints = false
54-
graphView.backgroundColor = .secondarySystemBackground
5554
graphView.setContentHuggingPriority(.required, for: .horizontal)
5655
graphView.setContentHuggingPriority(.required, for: .vertical)
5756
}
@@ -64,7 +63,7 @@ class StatsTotalInsightsCell: StatsBaseCell {
6463
countLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
6564
countLabel.setContentHuggingPriority(.required, for: .vertical)
6665

67-
comparisonLabel.font = .preferredFont(forTextStyle: .body)
66+
comparisonLabel.font = .preferredFont(forTextStyle: .subheadline)
6867
comparisonLabel.textColor = .textSubtle
6968
comparisonLabel.text = "+87 (40%) compared to last week"
7069
}
@@ -77,7 +76,7 @@ class StatsTotalInsightsCell: StatsBaseCell {
7776
outerStackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -StatsBaseCell.Metrics.padding),
7877
outerStackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: StatsBaseCell.Metrics.padding),
7978
outerStackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -StatsBaseCell.Metrics.padding),
80-
graphView.widthAnchor.constraint(equalTo: graphView.heightAnchor, multiplier: 2.55),
79+
graphView.widthAnchor.constraint(equalTo: graphView.heightAnchor, multiplier: Metrics.graphViewAspectRatio),
8180
graphView.heightAnchor.constraint(equalTo: countLabel.heightAnchor)
8281
])
8382
}
@@ -92,6 +91,8 @@ class StatsTotalInsightsCell: StatsBaseCell {
9291
}
9392

9493
private enum Metrics {
94+
static let outerStackViewSpacing: CGFloat = 16.0
9595
static let stackViewSpacing: CGFloat = 8.0
96+
static let graphViewAspectRatio: CGFloat = 3.27
9697
}
9798
}

WordPress/WordPress.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,8 @@
247247
1752D4FC238D703A002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; };
248248
175507B327A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; };
249249
175507B427A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; };
250+
1756F1DF2822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; };
251+
1756F1E02822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; };
250252
175721162754D31F00DE38BC /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175721152754D31F00DE38BC /* AppIcon.swift */; };
251253
175721172754D31F00DE38BC /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175721152754D31F00DE38BC /* AppIcon.swift */; };
252254
1759F1701FE017BF0003EC81 /* Queue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1759F16F1FE017BF0003EC81 /* Queue.swift */; };
@@ -5075,6 +5077,7 @@
50755077
1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+StyledHTML.swift"; sourceTree = "<group>"; };
50765078
17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsViewController.swift; sourceTree = "<group>"; };
50775079
175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeConnectionURLMatcher.swift; sourceTree = "<group>"; };
5080+
1756F1DE2822BB6F00CD0915 /* SparklineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineView.swift; sourceTree = "<group>"; };
50785081
175721152754D31F00DE38BC /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = "<group>"; };
50795082
1759F16F1FE017BF0003EC81 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = "<group>"; };
50805083
1759F1711FE017F20003EC81 /* QueueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueueTests.swift; sourceTree = "<group>"; };
@@ -12190,6 +12193,7 @@
1219012193
17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */,
1219112194
17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */,
1219212195
17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */,
12196+
1756F1DE2822BB6F00CD0915 /* SparklineView.swift */,
1219312197
);
1219412198
path = Insights;
1219512199
sourceTree = "<group>";
@@ -18397,6 +18401,7 @@
1839718401
4322A20D203E1885004EA740 /* SignupUsernameTableViewController.swift in Sources */,
1839818402
098B8576275E76FE004D299F /* AppLocalizedString.swift in Sources */,
1839918403
7E7BEF7022E1AED8009A880D /* Blog+Editor.swift in Sources */,
18404+
1756F1DF2822BB6F00CD0915 /* SparklineView.swift in Sources */,
1840018405
8BB185C624B5FB8500A4CCE8 /* ReaderCardService.swift in Sources */,
1840118406
98487E3A21EE8FB500352B4E /* UITableViewCell+Stats.swift in Sources */,
1840218407
E14BCABB1E0BC817002E0603 /* Delay.swift in Sources */,
@@ -21497,6 +21502,7 @@
2149721502
FABB259C2602FC2C00C8785C /* ReaderPostService+RelatedPosts.swift in Sources */,
2149821503
FABB259D2602FC2C00C8785C /* Blog+Capabilities.swift in Sources */,
2149921504
FABB259E2602FC2C00C8785C /* RestorePostTableViewCell.swift in Sources */,
21505+
1756F1E02822BB6F00CD0915 /* SparklineView.swift in Sources */,
2150021506
FABB259F2602FC2C00C8785C /* MenuItemCategoriesViewController.m in Sources */,
2150121507
FABB25A02602FC2C00C8785C /* UINavigationBar+Appearance.swift in Sources */,
2150221508
FABB25A12602FC2C00C8785C /* QuickStartChecklistViewController.swift in Sources */,

0 commit comments

Comments
 (0)