@@ -11,9 +11,9 @@ struct SmiliePickerView: View {
1111 @SwiftUI . Environment ( \. presentationMode) private var presentationMode : Binding < PresentationMode >
1212 @SwiftUI . Environment ( \. theme) private var theme : Theme
1313 @SwiftUI . Environment ( \. horizontalSizeClass) private var horizontalSizeClass
14- @ State private var visibleSections = 3 // Start by showing only first 3 sections
15- @State private var hasLoadedAllSections = false
16-
14+ // Uniform pill height (fits two lines of .subheadline), scales with Dynamic Type
15+ @ScaledMetric ( relativeTo : . subheadline ) private var pillHeight : CGFloat = 50
16+
1717 let onSmilieSelected : ( Smilie ) -> Void
1818
1919 private var columnCount : Int {
@@ -45,12 +45,6 @@ struct SmiliePickerView: View {
4545 errorView ( message: error)
4646 } else {
4747 scrollContent
48- . onAppear {
49- // Check if we already have all sections loaded from the start
50- if visibleSections >= viewModel. allSmilies. count {
51- hasLoadedAllSections = true
52- }
53- }
5448 }
5549 }
5650 }
@@ -124,6 +118,20 @@ struct SmiliePickerView: View {
124118 }
125119
126120 private var scrollContent : some View {
121+ GeometryReader { geometry in
122+ ScrollViewReader { proxy in
123+ VStack ( spacing: 0 ) {
124+ if viewModel. searchText. isEmpty && !viewModel. allSmilies. isEmpty {
125+ categoryChipRow ( proxy: proxy, maxPillWidth: geometry. size. width * 0.5 )
126+ }
127+
128+ scrollingSections
129+ }
130+ }
131+ }
132+ }
133+
134+ private var scrollingSections : some View {
127135 ScrollView {
128136 VStack ( alignment: . leading, spacing: 20 ) {
129137 if !viewModel. searchText. isEmpty {
@@ -138,6 +146,91 @@ struct SmiliePickerView: View {
138146 . padding ( . horizontal)
139147 }
140148 }
149+
150+ private var categoryTitles : [ String ] {
151+ var titles : [ String ] = [ ]
152+ if !viewModel. recentlyUsedSmilies. isEmpty {
153+ titles. append ( recentlyUsedSectionTitle)
154+ }
155+ titles. append ( contentsOf: viewModel. allSmilies. map { $0. title } )
156+ return titles
157+ }
158+
159+ /// Namespaces section anchor ids so they can't collide with the pill row's own
160+ /// `ForEach` identities — `ScrollViewProxy.scrollTo` searches every scroll view
161+ /// under the reader, and a bare title would match the (already visible) pill.
162+ private func sectionAnchorID( _ title: String ) -> String {
163+ " section- \( title) "
164+ }
165+
166+ private func categoryChipRow( proxy: ScrollViewProxy , maxPillWidth: CGFloat ) -> some View {
167+ // Alternate titles between the two rows so adjacent categories stay near each other
168+ let titles = categoryTitles
169+ let topRow = stride ( from: 0 , to: titles. count, by: 2 ) . map { titles [ $0] }
170+ let bottomRow = stride ( from: 1 , to: titles. count, by: 2 ) . map { titles [ $0] }
171+
172+ return ScrollView ( . horizontal, showsIndicators: false ) {
173+ VStack ( alignment: . leading, spacing: 8 ) {
174+ HStack ( spacing: 8 ) {
175+ ForEach ( topRow, id: \. self) { title in
176+ categoryPill ( title, proxy: proxy, maxWidth: maxPillWidth)
177+ }
178+ }
179+ if !bottomRow. isEmpty {
180+ HStack ( spacing: 8 ) {
181+ ForEach ( bottomRow, id: \. self) { title in
182+ categoryPill ( title, proxy: proxy, maxWidth: maxPillWidth)
183+ }
184+ }
185+ }
186+ }
187+ . padding ( . horizontal)
188+ }
189+ // Size to the two rows' content height; without this the surrounding layout
190+ // can compress the scroller and clip the bottom row
191+ . fixedSize ( horizontal: false , vertical: true )
192+ . padding ( . bottom, 8 )
193+ }
194+
195+ private func categoryPill( _ title: String , proxy: ScrollViewProxy , maxWidth: CGFloat ) -> some View {
196+ Button ( action: {
197+ // Deferring past the button's own transaction keeps scrollTo reliable on iOS 15
198+ DispatchQueue . main. async {
199+ withAnimation {
200+ proxy. scrollTo ( sectionAnchorID ( title) , anchor: . top)
201+ }
202+ }
203+ } ) {
204+ Text ( title)
205+ . font ( . subheadline)
206+ . dynamicTypeSize ( ... DynamicTypeSize . accessibility2)
207+ . multilineTextAlignment ( . center)
208+ . lineLimit ( 2 )
209+ . minimumScaleFactor ( 0.9 )
210+ . foregroundColor ( theme [ color: " sheetTextColor " ] !)
211+ . frame ( maxWidth: maxWidth)
212+ . padding ( . horizontal, 12 )
213+ . frame ( height: pillHeight)
214+ . background (
215+ Capsule ( )
216+ . fill ( pillBackgroundColor)
217+ . overlay (
218+ Capsule ( )
219+ . stroke ( pillStrokeColor, lineWidth: 1 )
220+ )
221+ )
222+ }
223+ }
224+
225+ private var pillBackgroundColor : Color {
226+ let fallback = theme. isDark ? Color . white. opacity ( 0.15 ) : Color . black. opacity ( 0.08 )
227+ return theme [ color: " listSecondaryTextColor " ] ? . opacity ( theme. isDark ? 0.25 : 0.2 ) ?? fallback
228+ }
229+
230+ private var pillStrokeColor : Color {
231+ let fallback = theme. isDark ? Color . white. opacity ( 0.3 ) : Color . black. opacity ( 0.2 )
232+ return theme [ color: " listSecondaryTextColor " ] ? . opacity ( 0.5 ) ?? fallback
233+ }
141234
142235 private var searchResultsSection : some View {
143236 VStack ( alignment: . leading, spacing: 10 ) {
@@ -167,10 +260,12 @@ struct SmiliePickerView: View {
167260 }
168261 }
169262
263+ private let recentlyUsedSectionTitle = " Recently Used "
264+
170265 private var recentlyUsedSection : some View {
171266 VStack ( alignment: . leading, spacing: 10 ) {
172267 HStack {
173- Text ( " Recently Used " )
268+ Text ( recentlyUsedSectionTitle )
174269 . font ( . title3)
175270 . fontWeight ( . bold)
176271 . dynamicTypeSize ( ... DynamicTypeSize . accessibility2)
@@ -181,53 +276,29 @@ struct SmiliePickerView: View {
181276
182277 smilieGrid ( viewModel. recentlyUsedSmilies)
183278 }
279+ . id ( sectionAnchorID ( recentlyUsedSectionTitle) )
184280 }
185281
186282 private var allSmiliesSection : some View {
187283 VStack ( alignment: . leading, spacing: 20 ) {
188- let sectionsToShow = Array ( viewModel. allSmilies. prefix ( visibleSections) )
189-
190- ForEach ( Array ( sectionsToShow. enumerated ( ) ) , id: \. element. title) { index, section in
284+ ForEach ( Array ( viewModel. allSmilies. enumerated ( ) ) , id: \. element. title) { index, section in
191285 VStack ( alignment: . leading, spacing: 10 ) {
192286 if index > 0 {
193287 Divider ( )
194288 . background ( theme [ color: " listSeparatorColor " ] !)
195289 . padding ( . vertical, 10 )
196290 }
197-
291+
198292 Text ( section. title)
199293 . font ( . title3)
200294 . fontWeight ( . bold)
201295 . dynamicTypeSize ( ... DynamicTypeSize . accessibility2)
202296 . foregroundColor ( theme [ color: " sheetTextColor " ] !)
203297 . padding ( . bottom, 5 )
204-
298+
205299 smilieGrid ( section. smilies)
206300 }
207- }
208-
209- // Show loading indicator if there are more sections to load
210- if !hasLoadedAllSections && visibleSections < viewModel. allSmilies. count && !viewModel. allSmilies. isEmpty {
211- HStack {
212- Spacer ( )
213- ProgressView ( )
214- . progressViewStyle ( CircularProgressViewStyle ( ) )
215- . scaleEffect ( 0.8 )
216- Spacer ( )
217- }
218- . padding ( . vertical, 20 )
219- . onAppear {
220- // Load more sections when this view appears
221- DispatchQueue . main. asyncAfter ( deadline: . now( ) + 0.3 ) {
222- withAnimation {
223- let newVisibleSections = min ( visibleSections + 3 , viewModel. allSmilies. count)
224- visibleSections = newVisibleSections
225- if newVisibleSections >= viewModel. allSmilies. count {
226- hasLoadedAllSections = true
227- }
228- }
229- }
230- }
301+ . id ( sectionAnchorID ( section. title) )
231302 }
232303 }
233304 }
0 commit comments