Skip to content

Commit 3c63070

Browse files
authored
Merge pull request #18622 from wordpress-mobile/feature/stats-insights-donut-chart
Stats Insights: Added donut chart component
2 parents e0464dd + 97281cf commit 3c63070

3 files changed

Lines changed: 265 additions & 1 deletion

File tree

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import UIKit
2+
3+
class DonutChartView: UIView {
4+
5+
// MARK: Views
6+
7+
private var segmentLayers = [CAShapeLayer]()
8+
9+
private var titleStackView: UIStackView!
10+
private var titleLabel: UILabel!
11+
private var totalCountLabel: UILabel!
12+
private var chartContainer: UIView!
13+
private var legendStackView: UIStackView!
14+
15+
// MARK: Configuration
16+
17+
struct Segment {
18+
let title: String
19+
let value: Float
20+
let color: UIColor
21+
}
22+
23+
var title: String? {
24+
didSet {
25+
titleLabel.text = title
26+
}
27+
}
28+
29+
var totalCount: Float = 0 {
30+
didSet {
31+
totalCountLabel.text = String(Int(totalCount))
32+
}
33+
}
34+
35+
var segments: [Segment] = []
36+
37+
// MARK: Initialization
38+
39+
override init(frame: CGRect) {
40+
super.init(frame: frame)
41+
42+
backgroundColor = .basicBackground
43+
44+
configureChartContainer()
45+
configureTitleViews()
46+
configureLegend()
47+
configureConstraints()
48+
}
49+
50+
required init?(coder: NSCoder) {
51+
fatalError("init(coder:) has not been implemented")
52+
}
53+
54+
private func configureChartContainer() {
55+
chartContainer = UIView()
56+
chartContainer.translatesAutoresizingMaskIntoConstraints = false
57+
addSubview(chartContainer)
58+
}
59+
60+
private func configureTitleViews() {
61+
titleLabel = UILabel()
62+
titleLabel.textAlignment = .center
63+
titleLabel.font = .preferredFont(forTextStyle: .subheadline)
64+
65+
totalCountLabel = UILabel()
66+
totalCountLabel.textAlignment = .center
67+
totalCountLabel.font = .preferredFont(forTextStyle: .title1).bold()
68+
69+
titleStackView = UIStackView(arrangedSubviews: [titleLabel, totalCountLabel])
70+
71+
titleStackView.translatesAutoresizingMaskIntoConstraints = false
72+
titleStackView.axis = .vertical
73+
titleStackView.spacing = Constants.titleStackViewSpacing
74+
75+
addSubview(titleStackView)
76+
}
77+
78+
private func configureLegend() {
79+
legendStackView = UIStackView()
80+
legendStackView.translatesAutoresizingMaskIntoConstraints = false
81+
legendStackView.spacing = Constants.legendStackViewSpacing
82+
legendStackView.distribution = .equalSpacing
83+
84+
addSubview(legendStackView)
85+
}
86+
87+
private func configureConstraints() {
88+
NSLayoutConstraint.activate([
89+
chartContainer.leadingAnchor.constraint(equalTo: leadingAnchor),
90+
chartContainer.trailingAnchor.constraint(equalTo: trailingAnchor),
91+
chartContainer.topAnchor.constraint(equalTo: topAnchor),
92+
93+
legendStackView.leadingAnchor.constraint(equalTo: leadingAnchor),
94+
legendStackView.trailingAnchor.constraint(equalTo: trailingAnchor),
95+
legendStackView.topAnchor.constraint(equalTo: chartContainer.bottomAnchor, constant: Constants.chartToLegendSpacing),
96+
legendStackView.bottomAnchor.constraint(equalTo: bottomAnchor),
97+
98+
titleStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.innerTextPadding),
99+
titleStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.innerTextPadding),
100+
titleStackView.centerYAnchor.constraint(equalTo: chartContainer.centerYAnchor),
101+
titleStackView.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: Constants.innerTextPadding),
102+
titleStackView.bottomAnchor.constraint(lessThanOrEqualTo: chartContainer.bottomAnchor, constant: -Constants.innerTextPadding)
103+
])
104+
}
105+
106+
/// Initializes the chart display with the provided data.
107+
///
108+
/// - Parameters:
109+
/// - title: Displayed in the center of the chart
110+
/// - totalCount: Displayed in the center of the chart and used to calculate segment sizes
111+
/// - segments: Used for color, legend titles, and segment size
112+
func configure(title: String?, totalCount: Float, segments: [Segment]) {
113+
if segments.reduce(0.0, { $0 + $1.value }) > totalCount {
114+
// DDLogInfo
115+
print("DonutChartView: Segment values should total less than 100%.")
116+
}
117+
118+
self.title = title
119+
self.totalCount = totalCount
120+
self.segments = segments
121+
122+
segments.forEach({ legendStackView.addArrangedSubview(LegendView(segment: $0)) })
123+
124+
layoutChart()
125+
}
126+
127+
private func layoutChart() {
128+
CATransaction.begin()
129+
CATransaction.setDisableActions(true)
130+
131+
// Clear out any existing segments
132+
segmentLayers.forEach({ $0.removeFromSuperlayer() })
133+
segmentLayers = []
134+
135+
guard totalCount > 0 else {
136+
// We must have a total count greater than 0, as we use it to calculate percentages
137+
print("DonutChartView: TotalCount must be greater than 0 for chart initialization.")
138+
return
139+
}
140+
141+
var currentTotal: Float = 0.0
142+
143+
for segment in segments {
144+
let segmentLayer = makeSegmentLayer()
145+
segmentLayer.strokeColor = segment.color.cgColor
146+
147+
// Calculate the start and end of the new segment
148+
let segmentStartPercentage = CGFloat(currentTotal / totalCount)
149+
currentTotal += segment.value
150+
let segmentEndPercentage = CGFloat(currentTotal / totalCount)
151+
152+
let path = UIBezierPath(arcCenter: chartCenterPoint,
153+
radius: chartRadius,
154+
startAngle: radiansFromPercent(segmentStartPercentage) + segmentOffset,
155+
endAngle: radiansFromPercent(segmentEndPercentage) - segmentOffset,
156+
clockwise: true)
157+
segmentLayer.path = path.cgPath
158+
159+
segmentLayers.append(segmentLayer)
160+
}
161+
162+
segmentLayers.forEach({ chartContainer.layer.addSublayer($0) })
163+
164+
CATransaction.commit()
165+
}
166+
167+
override func layoutSubviews() {
168+
super.layoutSubviews()
169+
170+
if !segmentLayers.isEmpty {
171+
layoutChart()
172+
}
173+
}
174+
175+
// MARK: Helpers
176+
177+
private func makeSegmentLayer() -> CAShapeLayer {
178+
let segmentLayer = CAShapeLayer()
179+
segmentLayer.frame = chartContainer.bounds
180+
segmentLayer.lineWidth = Constants.lineWidth
181+
segmentLayer.fillColor = UIColor.clear.cgColor
182+
segmentLayer.lineCap = .round
183+
184+
return segmentLayer
185+
}
186+
187+
private var chartCenterPoint: CGPoint {
188+
return CGPoint(x: chartContainer.bounds.midX, y: chartContainer.bounds.midY)
189+
}
190+
191+
private var chartRadius: CGFloat {
192+
let smallestDimension = min(chartContainer.bounds.width, chartContainer.bounds.height)
193+
return (smallestDimension / 2.0) - (Constants.lineWidth / 2.0)
194+
}
195+
196+
/// Offset used to adjust the endpoints of each chart segment so that the end caps
197+
/// don't overlap, as they draw from their center not from the line edge
198+
private var segmentOffset: CGFloat {
199+
return asin(Constants.lineWidth * 0.5 / chartRadius)
200+
}
201+
202+
private func radiansFromPercent(_ percent: CGFloat) -> CGFloat {
203+
return (percent * 2.0 * CGFloat.pi) - (CGFloat.pi / 2.0)
204+
}
205+
206+
// MARK: Constants
207+
208+
enum Constants {
209+
static let lineWidth: CGFloat = 16.0
210+
static let innerTextPadding: CGFloat = 24.0
211+
static let titleStackViewSpacing: CGFloat = 8.0
212+
static let legendStackViewSpacing: CGFloat = 8.0
213+
static let chartToLegendSpacing: CGFloat = 32.0
214+
}
215+
}
216+
217+
// MARK: - Legend View
218+
219+
private class LegendView: UIView {
220+
let segment: DonutChartView.Segment
221+
222+
init(segment: DonutChartView.Segment) {
223+
self.segment = segment
224+
225+
super.init(frame: .zero)
226+
227+
configureSubviews()
228+
}
229+
230+
required init?(coder: NSCoder) {
231+
fatalError("init(coder:) has not been implemented")
232+
}
233+
234+
private func configureSubviews() {
235+
let indicator = UIView()
236+
indicator.backgroundColor = segment.color
237+
indicator.layer.cornerRadius = 6.0
238+
239+
let titleLabel = UILabel()
240+
titleLabel.font = .preferredFont(forTextStyle: .subheadline)
241+
titleLabel.text = segment.title
242+
243+
let stackView = UIStackView(arrangedSubviews: [indicator, titleLabel])
244+
stackView.translatesAutoresizingMaskIntoConstraints = false
245+
stackView.spacing = 8.0
246+
addSubview(stackView)
247+
248+
NSLayoutConstraint.activate([
249+
indicator.widthAnchor.constraint(equalToConstant: 12.0),
250+
indicator.heightAnchor.constraint(equalToConstant: 12.0),
251+
252+
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
253+
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
254+
stackView.topAnchor.constraint(equalTo: topAnchor),
255+
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
256+
])
257+
}
258+
}

WordPress/Classes/ViewRelated/Stats/Insights/SparklineView.swift renamed to WordPress/Classes/ViewRelated/Stats/Charts/SparklineView.swift

File renamed without changes.

WordPress/WordPress.xcodeproj/project.pbxproj

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@
253253
1752D4FC238D703A002B79E7 /* KeyValueDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1751E5901CE0E552000CA08D /* KeyValueDatabase.swift */; };
254254
175507B327A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; };
255255
175507B427A062980038ED28 /* PublicizeConnectionURLMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */; };
256+
1756DBDF28328B76006E6DB9 /* DonutChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */; };
257+
1756DBE028328B76006E6DB9 /* DonutChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756DBDE28328B76006E6DB9 /* DonutChartView.swift */; };
256258
1756F1DF2822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; };
257259
1756F1E02822BB6F00CD0915 /* SparklineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1756F1DE2822BB6F00CD0915 /* SparklineView.swift */; };
258260
175721162754D31F00DE38BC /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 175721152754D31F00DE38BC /* AppIcon.swift */; };
@@ -5123,6 +5125,7 @@
51235125
1751E5921CE23801000CA08D /* NSAttributedString+StyledHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+StyledHTML.swift"; sourceTree = "<group>"; };
51245126
17523380246C4F9200870B4A /* HomepageSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomepageSettingsViewController.swift; sourceTree = "<group>"; };
51255127
175507B227A062980038ED28 /* PublicizeConnectionURLMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicizeConnectionURLMatcher.swift; sourceTree = "<group>"; };
5128+
1756DBDE28328B76006E6DB9 /* DonutChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DonutChartView.swift; sourceTree = "<group>"; };
51265129
1756F1DE2822BB6F00CD0915 /* SparklineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineView.swift; sourceTree = "<group>"; };
51275130
175721152754D31F00DE38BC /* AppIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIcon.swift; sourceTree = "<group>"; };
51285131
1759F16F1FE017BF0003EC81 /* Queue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Queue.swift; sourceTree = "<group>"; };
@@ -10894,6 +10897,8 @@
1089410897
DC772AEF282009BA00664C02 /* StatsLineChartConfiguration.swift */,
1089510898
DC772AF0282009BA00664C02 /* StatsLineChartView.swift */,
1089610899
73D86968223AF4040064920F /* StatsChartLegendView.swift */,
10900+
1756F1DE2822BB6F00CD0915 /* SparklineView.swift */,
10901+
1756DBDE28328B76006E6DB9 /* DonutChartView.swift */,
1089710902
);
1089810903
path = Charts;
1089910904
sourceTree = "<group>";
@@ -12283,7 +12288,6 @@
1228312288
17ABD3512811A48900B1E9CB /* StatsMostPopularTimeInsightsCell.swift */,
1228412289
17870A6F2816F2A000D1C627 /* StatsLatestPostSummaryInsightsCell.swift */,
1228512290
17870A73281FBEC000D1C627 /* StatsTotalInsightsCell.swift */,
12286-
1756F1DE2822BB6F00CD0915 /* SparklineView.swift */,
1228712291
);
1228812292
path = Insights;
1228912293
sourceTree = "<group>";
@@ -18600,6 +18604,7 @@
1860018604
173DF291274522A1007C64B5 /* AppAboutScreenConfiguration.swift in Sources */,
1860118605
3F3CA65025D3003C00642A89 /* StatsWidgetsStore.swift in Sources */,
1860218606
0857C2791CE5375F0014AE99 /* MenuItemsVisualOrderingView.m in Sources */,
18607+
1756DBDF28328B76006E6DB9 /* DonutChartView.swift in Sources */,
1860318608
C700F9EE257FD64E0090938E /* JetpackScanViewController.swift in Sources */,
1860418609
D8212CB520AA68D5008E8AE8 /* ReaderSubscribingNotificationAction.swift in Sources */,
1860518610
FF8C54AD21F677260003ABCF /* GutenbergMediaInserterHelper.swift in Sources */,
@@ -21500,6 +21505,7 @@
2150021505
FABB25802602FC2C00C8785C /* WPStyleGuide+Loader.swift in Sources */,
2150121506
FABB25812602FC2C00C8785C /* MediaThumbnailService.swift in Sources */,
2150221507
FABB25822602FC2C00C8785C /* MediaExporter.swift in Sources */,
21508+
1756DBE028328B76006E6DB9 /* DonutChartView.swift in Sources */,
2150321509
8B15CDAC27EB89AD00A75749 /* BlogDashboardPostsParser.swift in Sources */,
2150421510
8BF9E03427B1A8A800915B27 /* DashboardCard.swift in Sources */,
2150521511
FABB25832602FC2C00C8785C /* ReaderRelatedPostsCell.swift in Sources */,

0 commit comments

Comments
 (0)