11import SwiftUI
22import DesignSystem
33import UniformTypeIdentifiers
4+ import WordPressUI
45
56struct 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
0 commit comments