Skip to content

Commit 3bf316d

Browse files
authored
New Stats: Improve Top List screen (#24868)
* Show top 50 items in Top List * Show top 10 in a separate list * Show top 10 * Hardcode separate l10n values for top 10 and top 50
1 parent e249c12 commit 3bf316d

4 files changed

Lines changed: 160 additions & 63 deletions

File tree

Modules/Sources/JetpackStats/Screens/TopListScreenView.swift

Lines changed: 145 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import SwiftUI
22
import DesignSystem
33
import UniformTypeIdentifiers
4+
import WordPressUI
45

56
struct TopListScreenView: View {
67
@StateObject private var viewModel: TopListViewModel
8+
@State private var isShowingAllItems = false
79

810
@Environment(\.router) var router
911
@Environment(\.context) var context
@@ -33,32 +35,40 @@ struct TopListScreenView: View {
3335
}
3436

3537
var body: some View {
36-
ScrollView {
37-
VStack(spacing: Constants.step4) {
38+
List {
39+
Group {
3840
headerView
3941
.background(Color(.secondarySystemBackground).opacity(0.7))
4042
.cardStyle()
4143
.dynamicTypeSize(...DynamicTypeSize.xLarge)
4244
.accessibilityElement(children: .contain)
4345
.padding(.horizontal, Constants.step1)
46+
.padding(.top, Constants.step1)
4447

45-
VStack {
46-
listHeaderView
47-
.padding(.horizontal, Constants.step1)
48-
.dynamicTypeSize(...DynamicTypeSize.xLarge)
49-
listContentView
50-
.grayscale(viewModel.isStale ? 1 : 0)
51-
.animation(.smooth, value: viewModel.isStale)
48+
Group {
49+
if viewModel.isFirstLoad {
50+
listContent(data: mockData())
51+
.redacted(reason: .placeholder)
52+
.pulsating()
53+
} else if let data = viewModel.data {
54+
if data.items.isEmpty {
55+
makeEmptyStateView(message: Strings.Chart.empty)
56+
} else {
57+
listContent(data: data)
58+
}
59+
} else {
60+
makeEmptyStateView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic)
61+
}
5262
}
5363
.padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass))
5464
}
65+
.listRowSeparator(.hidden)
66+
.listRowInsets(.zero)
5567
.dynamicTypeSize(...DynamicTypeSize.xxxLarge)
56-
.padding(.vertical, Constants.step2)
57-
.frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidthPlainLists : .infinity)
58-
.frame(maxWidth: .infinity)
59-
.animation(.spring, value: viewModel.data.map(ObjectIdentifier.init))
6068
}
61-
.background(Color(.systemBackground))
69+
.animation(.default, value: viewModel.data.map(ObjectIdentifier.init))
70+
.listStyle(.plain)
71+
.environment(\.defaultMinListRowHeight, 1)
6272
.navigationTitle(viewModel.selection.item.localizedTitle)
6373
.navigationBarTitleDisplayMode(.inline)
6474
.toolbar {
@@ -106,9 +116,9 @@ struct TopListScreenView: View {
106116
@ViewBuilder
107117
private var headerView: some View {
108118
HStack(alignment: .center, spacing: Constants.step1) {
109-
VStack(alignment: .leading, spacing: 4) {
110-
Text(viewModel.selection.item.getTitle(for: viewModel.selection.metric))
111-
.font(.headline)
119+
VStack(alignment: .leading, spacing: 2) {
120+
Text(viewModel.selection.metric.localizedTitle)
121+
.font(.title3.weight(.medium))
112122
.foregroundColor(.primary)
113123
Text(context.formatters.dateRange.string(from: viewModel.dateRange.dateInterval))
114124
.font(.subheadline)
@@ -118,7 +128,7 @@ struct TopListScreenView: View {
118128
Spacer()
119129

120130
// Always show the metrics view to preserve identity
121-
metricsOverviewView(data: viewModel.data ?? mockData)
131+
metricsOverviewView(data: viewModel.data ?? mockData())
122132
.redacted(reason: viewModel.isFirstLoad ? .placeholder : [])
123133
.pulsating(viewModel.isFirstLoad)
124134
.animation(.smooth, value: viewModel.isFirstLoad)
@@ -127,20 +137,6 @@ struct TopListScreenView: View {
127137
.padding(.horizontal, Constants.step3)
128138
}
129139

130-
private var listHeaderView: some View {
131-
HStack {
132-
Text(viewModel.selection.item.localizedTitle)
133-
.font(.subheadline)
134-
.fontWeight(.medium)
135-
136-
Spacer()
137-
138-
Text(viewModel.selection.metric.localizedTitle)
139-
.font(.subheadline)
140-
.fontWeight(.medium)
141-
}
142-
}
143-
144140
@ViewBuilder
145141
private func metricsOverviewView(data: TopListData) -> some View {
146142
let formattedValue = StatsValueFormatter(metric: data.metric)
@@ -166,53 +162,140 @@ struct TopListScreenView: View {
166162
}
167163
}
168164

165+
// MARK: - Lists
166+
167+
enum ListSection {
168+
case top10
169+
case top50
170+
case other
171+
172+
var title: String {
173+
switch self {
174+
case .top10: Strings.TopListTitles.top10
175+
case .top50: Strings.TopListTitles.top50
176+
case .other: ""
177+
}
178+
}
179+
}
180+
169181
@ViewBuilder
170-
private var listContentView: some View {
171-
if viewModel.isFirstLoad {
172-
itemsListView(data: mockData)
173-
.redacted(reason: .placeholder)
174-
.pulsating()
175-
} else if let data = viewModel.data {
176-
if data.items.isEmpty {
177-
makeEmptyStateView(message: Strings.Chart.empty)
182+
private func listContent(data: TopListData) -> some View {
183+
if data.items.count > 0 {
184+
listSection(.top10, data: data)
185+
}
186+
if data.items.count > 10 {
187+
listSection(.top50, data: data)
188+
}
189+
if data.items.count > 50 {
190+
if isShowingAllItems {
191+
listSection(.other, data: data)
178192
} else {
179-
itemsListView(data: data)
193+
showMoreButton
180194
}
195+
}
196+
}
197+
198+
@ViewBuilder
199+
private func listSection(_ section: ListSection, data: TopListData) -> some View {
200+
if section == .other {
201+
VStack(spacing: 0) {
202+
Spacer().frame(height: 20)
203+
Divider()
204+
Spacer().frame(height: 20)
205+
}
206+
.padding(.horizontal, Constants.step1)
181207
} else {
182-
makeEmptyStateView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic)
208+
listHeaderView(title: section.title)
209+
.padding(EdgeInsets(top: Constants.step3, leading: Constants.step1, bottom: Constants.step0_5, trailing: Constants.step1))
210+
.dynamicTypeSize(...DynamicTypeSize.xLarge)
211+
}
212+
listForEach(for: section, data: data)
213+
.grayscale(viewModel.isStale ? 1 : 0)
214+
.animation(.smooth, value: viewModel.isStale)
215+
}
216+
217+
private func listHeaderView(title: String) -> some View {
218+
HStack {
219+
Text(title)
220+
.font(.subheadline)
221+
.fontWeight(.medium)
222+
223+
Spacer()
224+
225+
Text(viewModel.selection.metric.localizedTitle)
226+
.font(.subheadline)
227+
.fontWeight(.medium)
183228
}
184229
}
185230

186-
private func itemsListView(data: TopListData) -> some View {
187-
VStack(spacing: Constants.step0_5) {
188-
ForEach(data.items, id: \.id) { item in
189-
TopListItemView(
190-
item: item,
191-
previousValue: data.previousItem(for: item)?.metrics[viewModel.selection.metric],
192-
metric: viewModel.selection.metric,
193-
maxValue: data.metrics.maxValue,
194-
dateRange: viewModel.dateRange
195-
)
196-
.frame(height: TopListItemView.defaultCellHeight)
231+
@ViewBuilder
232+
private func listForEach(for section: ListSection, data: TopListData) -> some View {
233+
let items = getDisplayedItems(from: data.items, section: section)
234+
ForEach(items, id: \.element.id) { index, item in
235+
TopListItemView(
236+
index: index < 50 ? index : nil,
237+
item: item,
238+
previousValue: data.previousItem(for: item)?.metrics[viewModel.selection.metric],
239+
metric: viewModel.selection.metric,
240+
maxValue: data.metrics.maxValue,
241+
dateRange: viewModel.dateRange
242+
)
243+
.frame(height: TopListItemView.defaultCellHeight)
244+
}
245+
.listRowInsets(EdgeInsets(top: Constants.step0_5 / 2, leading: 0, bottom: Constants.step0_5 / 2, trailing: 0))
246+
}
247+
248+
private func getDisplayedItems(
249+
from items: [any TopListItemProtocol],
250+
section: ListSection
251+
) -> [(offset: Int, element: any TopListItemProtocol)] {
252+
switch section {
253+
case .top10:
254+
return Array(items.enumerated().prefix(10))
255+
case .top50:
256+
return Array(items.enumerated().prefix(50).dropFirst(10))
257+
case .other:
258+
return Array(items.enumerated().dropFirst(50))
259+
}
260+
}
261+
262+
private var showMoreButton: some View {
263+
Button {
264+
withAnimation(.spring) {
265+
isShowingAllItems = true
266+
}
267+
} label: {
268+
HStack(spacing: 4) {
269+
Text(Strings.Buttons.showMore)
270+
.font(.subheadline)
271+
.fontWeight(.medium)
272+
Image(systemName: "chevron.down")
273+
.font(.caption)
274+
.fontWeight(.medium)
197275
}
198276
}
277+
.buttonStyle(.plain)
278+
.frame(maxWidth: .infinity)
279+
.padding(.vertical, Constants.step2)
199280
}
200281

201282
private func makeEmptyStateView(message: String) -> some View {
202-
itemsListView(data: mockData)
203-
.redacted(reason: .placeholder)
204-
.grayscale(1)
205-
.opacity(0.25)
206-
.overlay {
207-
SimpleErrorView(message: message)
208-
}
283+
VStack {
284+
listContent(data: mockData(count: 6))
285+
}
286+
.redacted(reason: .placeholder)
287+
.grayscale(1)
288+
.opacity(0.25)
289+
.overlay {
290+
SimpleErrorView(message: message)
291+
}
209292
}
210293

211-
private var mockData: TopListData {
294+
private func mockData(count: Int = 10) -> TopListData {
212295
TopListData.mock(
213296
for: viewModel.selection.item,
214297
metric: viewModel.selection.metric,
215-
itemCount: 10
298+
itemCount: count
216299
)
217300
}
218301

Modules/Sources/JetpackStats/Strings.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ enum Strings {
122122
static let highestBounceRate = AppLocalizedString("jetpackStats.topList.highestBounceRate", value: "Highest Bounce Rate", comment: "Title for items with highest bounce rate")
123123
static let longestTimeOnSite = AppLocalizedString("jetpackStats.topList.longestTimeOnSite", value: "Longest Time on Site", comment: "Title for items with longest time on site")
124124
static let mostDownloadeded = AppLocalizedString("jetpackStats.topList.mostDownloads", value: "Most Downloaded", comment: "Title for chart")
125+
static let top10 = AppLocalizedString("jetpackStats.postDetails.top10", value: "Top 10", comment: "Section title")
126+
static let top50 = AppLocalizedString("jetpackStats.postDetails.top50", value: "Top 50", comment: "Section title")
125127
}
126128

127129
enum Errors {

Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import DesignSystem
44
struct TopListItemView: View {
55
static let defaultCellHeight: CGFloat = 52
66

7+
var index: Int?
78
let item: any TopListItemProtocol
89
let previousValue: Int?
910
let metric: SiteMetric
@@ -52,6 +53,15 @@ struct TopListItemView: View {
5253

5354
var content: some View {
5455
HStack(alignment: .center, spacing: 0) {
56+
if let index {
57+
Text("\(index + 1)")
58+
.font(.system(.subheadline, design: .rounded, weight: .medium))
59+
.frame(width: 22, alignment: .center)
60+
.lineLimit(1)
61+
.dynamicTypeSize(...DynamicTypeSize.large)
62+
.padding(.trailing, 8)
63+
}
64+
5565
// Content-specific view
5666
switch item {
5767
case let post as TopListItem.Post:
@@ -92,7 +102,7 @@ struct TopListItemView: View {
92102
.padding(.trailing, -3)
93103
.dynamicTypeSize(...DynamicTypeSize.xLarge)
94104
}
95-
.dynamicTypeSize(...DynamicTypeSize.xxLarge)
105+
.dynamicTypeSize(...DynamicTypeSize.xLarge)
96106
.padding(.horizontal, Constants.step1)
97107
.frame(height: cellHeight)
98108
.contentShape(Rectangle())

RELEASE-NOTES.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* [*] Fix a few minor visual issues on iOS 26 [#24863], [#24863], [#24865], [#24866], [#24867]
44
* [*] [Intelligence] Add support for generating excerpts for posts [#24852]
55
* [*] Fix performance issues with search in Reader Subscriptions [#24841]
6+
* [*] Improve performance and animations when showing a full Top List with a large number of items [#24868]
7+
* [*] Show the Top 10 and 50 items in the Top List screen in a dedicated section [#24868]
68

79
26.3.1
810
------

0 commit comments

Comments
 (0)