Skip to content

Commit 02800d8

Browse files
Improvements in search and loading
1 parent f2be24b commit 02800d8

File tree

7 files changed

+308
-123
lines changed

7 files changed

+308
-123
lines changed

Package.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
// swift-tools-version:5.5
1+
// swift-tools-version:5.9
22
// The swift-tools-version declares the minimum version of Swift required to build this package.
33

44
import PackageDescription
55

66
let package = Package(
77
name: "SFSymbolsPicker",
8-
platforms: [.iOS(.v15), .macOS(.v12)],
8+
platforms: [.iOS(.v17), .macOS(.v14)],
99
products: [
1010
// Products define the executables and libraries a package produces, and make them visible to other packages.
1111
.library(
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// SwiftUIView.swift
3+
//
4+
//
5+
// Created by Alessio Rubicini on 06/01/21.
6+
//
7+
8+
import Foundation
9+
import SwiftUI
10+
11+
struct ContentView: View {
12+
@State private var isSheetPresented = false
13+
@State private var icon = "star.fill"
14+
@State private var iconSize: CGFloat = 50
15+
16+
var body: some View {
17+
NavigationStack {
18+
VStack(spacing: 30) {
19+
// Selected Symbol Preview
20+
VStack(spacing: 16) {
21+
Image(systemName: icon)
22+
.font(.system(size: iconSize))
23+
.foregroundColor(.accentColor)
24+
.frame(width: 100, height: 100)
25+
.background(
26+
RoundedRectangle(cornerRadius: 20)
27+
.fill(Color.accentColor.opacity(0.1))
28+
)
29+
30+
Text(icon)
31+
.font(.subheadline)
32+
.foregroundColor(.secondary)
33+
}
34+
.padding()
35+
36+
// Open Picker Button
37+
Button {
38+
isSheetPresented.toggle()
39+
} label: {
40+
HStack {
41+
Image(systemName: "square.grid.2x2")
42+
Text("Choose Symbol")
43+
}
44+
.font(.headline)
45+
.foregroundColor(.white)
46+
.frame(maxWidth: .infinity)
47+
.padding()
48+
.background(Color.accentColor)
49+
.cornerRadius(15)
50+
}
51+
.padding(.horizontal)
52+
}
53+
.padding()
54+
.navigationTitle("SF Symbols Picker")
55+
.sheet(isPresented: $isSheetPresented) {
56+
SymbolsPicker(
57+
selection: $icon,
58+
title: "Choose your symbol",
59+
searchLabel: "Search symbols...",
60+
autoDismiss: true
61+
) {
62+
Image(systemName: "xmark.circle")
63+
.foregroundColor(.accentColor)
64+
}
65+
}
66+
}
67+
}
68+
}
69+
70+
#Preview {
71+
ContentView()
72+
}

Sources/SFSymbolsPicker/SymbolIcon.swift

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,48 @@ struct SymbolIcon: View {
1212
let symbolName: String
1313
@Binding var selection: String
1414

15+
private let size: CGFloat = 44
16+
private let cornerRadius: CGFloat = 8
17+
@State private var isPressed: Bool = false
18+
1519
var body: some View {
16-
Image(systemName: symbolName)
17-
.font(.system(size: 25))
18-
.animation(.linear)
19-
.foregroundColor(self.selection == symbolName ? Color.accentColor : Color.primary)
20-
.onTapGesture {
21-
// Assign binding value
22-
withAnimation {
23-
self.selection = symbolName
24-
}
20+
Button {
21+
withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
22+
selection = symbolName
2523
}
24+
} label: {
25+
Image(systemName: symbolName)
26+
.font(.system(size: 24, weight: .regular))
27+
.frame(width: size, height: size)
28+
.background(
29+
RoundedRectangle(cornerRadius: cornerRadius)
30+
.fill(selection == symbolName ?
31+
Color.accentColor.opacity(0.15) :
32+
Color.clear)
33+
)
34+
.foregroundColor(selection == symbolName ?
35+
.accentColor :
36+
.primary)
37+
.scaleEffect(isPressed ? 0.95 : 1.0)
38+
.animation(.easeInOut(duration: 0.1), value: isPressed)
39+
}
40+
.buttonStyle(SymbolButtonStyle(isPressed: $isPressed))
41+
.contentShape(Rectangle())
2642
}
2743

2844
}
2945

46+
private struct SymbolButtonStyle: ButtonStyle {
47+
@Binding var isPressed: Bool
48+
49+
func makeBody(configuration: Configuration) -> some View {
50+
configuration.label
51+
.onChange(of: configuration.isPressed) { oldValue, newValue in
52+
isPressed = newValue
53+
}
54+
}
55+
}
56+
3057
#Preview {
3158
SymbolIcon(symbolName: "beats.powerbeatspro", selection: .constant("star.bubble"))
3259
}

Sources/SFSymbolsPicker/SymbolLoader.swift

Lines changed: 87 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,59 +7,114 @@
77

88
import Foundation
99

10+
private extension String {
11+
func fuzzyMatch(_ pattern: String) -> Bool {
12+
let pattern = pattern.lowercased()
13+
let string = self.lowercased()
14+
15+
if pattern.isEmpty { return true }
16+
if string.isEmpty { return false }
17+
18+
var patternIndex = pattern.startIndex
19+
var stringIndex = string.startIndex
20+
21+
while patternIndex < pattern.endIndex && stringIndex < string.endIndex {
22+
if pattern[patternIndex] == string[stringIndex] {
23+
patternIndex = pattern.index(after: patternIndex)
24+
}
25+
stringIndex = string.index(after: stringIndex)
26+
}
27+
28+
return patternIndex == pattern.endIndex
29+
}
30+
}
31+
1032
// This class is responsible for loading symbols from system
1133
public class SymbolLoader {
12-
1334
private let symbolsPerPage = 100
1435
private var currentPage = 0
1536
private final var allSymbols: [String] = []
16-
37+
private var retryCount = 0 // Prevent infinite retries
38+
private var loadedSymbols: [String] = []
39+
1740
public init() {
18-
self.allSymbols = getAllSymbols()
41+
// Load symbols asynchronously
42+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
43+
self?.loadAllSymbols()
44+
}
1945
}
2046

21-
// Retrieves symbols for the current page
2247
public func getSymbols() -> [String] {
23-
currentPage += 1
48+
if currentPage == 0 {
49+
currentPage = 1
50+
let endIndex = min(symbolsPerPage, allSymbols.count)
51+
loadedSymbols = Array(allSymbols[0..<endIndex])
52+
}
53+
return loadedSymbols
54+
}
2455

25-
// Calculate start and end index for the requested page
56+
public func loadNextPage() -> [String] {
57+
guard hasMoreSymbols() else { return [] }
58+
currentPage += 1
2659
let startIndex = (currentPage - 1) * symbolsPerPage
2760
let endIndex = min(startIndex + symbolsPerPage, allSymbols.count)
28-
29-
// Extract symbols for the page
30-
return Array(allSymbols[startIndex..<endIndex])
61+
let newSymbols = Array(allSymbols[startIndex..<endIndex])
62+
loadedSymbols.append(contentsOf: newSymbols)
63+
return newSymbols
3164
}
32-
33-
// Retrieves symbols that start with the specified name
65+
3466
public func getSymbols(named name: String) -> [String] {
35-
return allSymbols.filter({$0.lowercased().starts(with: name.lowercased())})
67+
if name.isEmpty { return [] }
68+
69+
// First try exact matches
70+
let exactMatches = allSymbols.filter { $0.lowercased().starts(with: name.lowercased()) }
71+
if !exactMatches.isEmpty {
72+
return exactMatches
73+
}
74+
75+
// Then try fuzzy matches
76+
return allSymbols.filter { $0.fuzzyMatch(name) }
3677
}
37-
38-
// Checks if there are more symbols available
78+
3979
public func hasMoreSymbols() -> Bool {
40-
return currentPage * symbolsPerPage < allSymbols.count
80+
let nextPageStart = currentPage * symbolsPerPage
81+
return nextPageStart < allSymbols.count
4182
}
42-
43-
// Resets the pagination to the initial state
83+
4484
public func resetPagination() {
4585
currentPage = 0
86+
loadedSymbols.removeAll()
4687
}
47-
48-
// Loads all symbols from the plist file
49-
private func getAllSymbols() -> [String] {
50-
var allSymbols = [String]()
51-
if let bundle = Bundle(identifier: "com.apple.CoreGlyphs"),
52-
let resourcePath = bundle.path(forResource: "name_availability", ofType: "plist"),
53-
let plist = NSDictionary(contentsOfFile: resourcePath),
54-
let plistSymbols = plist["symbols"] as? [String: String]
55-
{
56-
// Get all symbol names
57-
allSymbols = Array(plistSymbols.keys)
88+
89+
private func loadAllSymbols() {
90+
guard let bundle = Bundle(identifier: "com.apple.CoreGlyphs") else {
91+
print("Failed: Bundle 'com.apple.CoreGlyphs' not found. Retrying...")
92+
if retryCount < 3 { // Prevent infinite retries
93+
retryCount += 1
94+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
95+
self.loadAllSymbols() // Retry loading
96+
}
97+
}
98+
return
5899
}
59-
return allSymbols.sorted(by: {
60-
$1 > $0
61-
})
62-
}
63100

101+
guard let resourcePath = bundle.path(forResource: "name_availability", ofType: "plist"),
102+
let plist = NSDictionary(contentsOfFile: resourcePath),
103+
let plistSymbols = plist["symbols"] as? [String: String] else {
104+
return
105+
}
106+
107+
print("Successfully loaded \(plistSymbols.count) SF Symbols.")
108+
allSymbols = Array(plistSymbols.keys).sorted(by: { $1 > $0 })
109+
loadedSymbols = Array(allSymbols.prefix(symbolsPerPage))
110+
111+
// Notify ViewModel to update UI on the main queue
112+
DispatchQueue.main.async {
113+
NotificationCenter.default.post(name: .symbolsLoaded, object: nil)
114+
}
115+
}
64116
}
65117

118+
extension Notification.Name {
119+
static let symbolsLoaded = Notification.Name("symbolsLoaded")
120+
}

Sources/SFSymbolsPicker/SymbolsPicker.swift

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,59 @@ public struct SymbolsPicker<Content: View>: View {
3131

3232
@ViewBuilder
3333
public var body: some View {
34-
NavigationView {
34+
NavigationStack {
3535
VStack {
36-
ScrollView(.vertical) {
37-
LazyVGrid(columns: [GridItem(.adaptive(minimum: 70))], spacing: 20) {
38-
ForEach(vm.symbols, id: \.hash) { icon in
39-
Button {
40-
withAnimation {
41-
self.selection = icon
36+
Group {
37+
if(vm.isLoading) {
38+
ProgressView()
39+
.scaleEffect(1.2)
40+
.frame(maxWidth: .infinity, maxHeight: .infinity)
41+
} else if vm.symbols.isEmpty && !searchText.isEmpty {
42+
ContentUnavailableView {
43+
Label("No Symbols Found", systemImage: "magnifyingglass")
44+
} description: {
45+
Text("Try searching for something else")
46+
}
47+
} else {
48+
ScrollView(.vertical) {
49+
LazyVGrid(
50+
columns: [
51+
GridItem(.adaptive(minimum: 60, maximum: 80), spacing: 16)
52+
],
53+
spacing: 16
54+
) {
55+
ForEach(vm.symbols, id: \.hash) { icon in
56+
Button {
57+
withAnimation {
58+
self.selection = icon
59+
}
60+
} label: {
61+
SymbolIcon(symbolName: icon, selection: $selection)
62+
}
63+
}
64+
65+
if vm.hasMoreSymbols && searchText.isEmpty {
66+
if vm.isLoadingMore {
67+
ProgressView()
68+
.padding()
69+
} else {
70+
Color.clear
71+
.frame(height: 1)
72+
.onAppear {
73+
vm.loadMoreSymbols()
74+
}
75+
}
4276
}
43-
} label: {
44-
SymbolIcon(symbolName: icon, selection: $selection)
4577
}
46-
47-
}.padding(.top, 5)
48-
}
49-
50-
if(vm.hasMoreSymbols && searchText.isEmpty) {
51-
Button(action: {
52-
vm.loadSymbols()
53-
}, label: {
54-
Label("Load More", systemImage: "square.and.arrow.down")
55-
}).padding()
78+
.padding(.horizontal)
79+
}
80+
.scrollIndicators(.hidden)
81+
.scrollDisabled(false)
82+
.simultaneousGesture(
83+
DragGesture(minimumDistance: 10)
84+
.onChanged { _ in }
85+
)
86+
.scrollDismissesKeyboard(.immediately)
5687
}
5788
}
5889
.navigationTitle(vm.title)
@@ -68,19 +99,17 @@ public struct SymbolsPicker<Content: View>: View {
6899
}
69100
}
70101
}
71-
.padding(.vertical, 5)
72-
73-
}.padding(.horizontal, 5)
74-
.searchable(text: $searchText, prompt: vm.searchbarLabel)
102+
}
103+
.searchable(text: $searchText, prompt: vm.searchbarLabel)
75104
}
76105

77-
.onChange(of: selection) { newValue in
106+
.onChange(of: selection) { oldValue, newValue in
78107
if(vm.autoDismiss) {
79108
presentationMode.wrappedValue.dismiss()
80109
}
81110
}
82111

83-
.onChange(of: searchText) { newValue in
112+
.onChange(of: searchText) { oldValue, newValue in
84113
if(newValue.isEmpty || searchText.isEmpty) {
85114
vm.reset()
86115
} else {

0 commit comments

Comments
 (0)