Last active
December 3, 2024 09:12
-
-
Save Codelaby/a1479efc4dca33cd95741cad09b49a51 to your computer and use it in GitHub Desktop.
List music swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| struct DebouncingTaskViewModifier<ID: Equatable>: ViewModifier { | |
| let id: ID | |
| let priority: TaskPriority | |
| let duration: Duration | |
| let task: @Sendable () async -> Void | |
| init( | |
| id: ID, | |
| priority: TaskPriority = .userInitiated, | |
| duration: Duration = .nanoseconds(0), | |
| task: @Sendable @escaping () async -> Void | |
| ) { | |
| self.id = id | |
| self.priority = priority | |
| self.duration = duration | |
| self.task = task | |
| } | |
| func body(content: Content) -> some View { | |
| content.task(id: id, priority: priority) { | |
| do { | |
| try await Task.sleep(for: duration) | |
| await task() | |
| } catch { | |
| // Ignore cancellation | |
| } | |
| } | |
| } | |
| } | |
| extension View { | |
| func task<ID: Equatable>( | |
| id: ID, | |
| priority: TaskPriority = .userInitiated, | |
| duration: Duration = .nanoseconds(0), | |
| task: @Sendable @escaping () async -> Void | |
| ) -> some View { | |
| modifier( | |
| DebouncingTaskViewModifier( | |
| id: id, | |
| priority: priority, | |
| duration: duration, | |
| task: task | |
| ) | |
| ) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // SearchScopePlayground.swift | |
| // FieldsPlayground | |
| // | |
| // Created by Codelaby on 9/11/24. | |
| // | |
| import SwiftUI | |
| // MARK: Model | |
| enum MusicGenre: String, Identifiable, CaseIterable, Hashable, Sendable, CustomStringConvertible { | |
| case classical = "Classical" | |
| case jazz = "Jazz" | |
| case rock = "Rock" | |
| case pop = "Pop" | |
| case electronic = "Electronic" | |
| case hipHop = "Hip Hop" | |
| case country = "Country" | |
| case folk = "Folk" | |
| case blues = "Blues" | |
| case reggae = "Reggae" | |
| case kpop = "K-pop" | |
| case trance = "Trance" | |
| var id: String { rawValue } | |
| // Custom description | |
| var description: String { | |
| switch self { | |
| case .classical: return "A timeless genre with rich harmonies and melodies." | |
| case .jazz: return "A genre known for improvisation and swing." | |
| case .rock: return "High-energy music with electric guitars and strong rhythms." | |
| case .pop: return "Catchy tunes that appeal to a broad audience." | |
| case .electronic: return "Music created with synthesizers and digital sounds." | |
| case .hipHop: return "Rhythmic music with rap and beats." | |
| case .country: return "Music with storytelling and acoustic instruments." | |
| case .folk: return "Traditional music with cultural roots." | |
| case .blues: return "Soulful music expressing deep emotions." | |
| case .reggae: return "Music with laid-back rhythms and Caribbean vibes." | |
| case .kpop: return "Korean pop music with catchy melodies and energetic performances." | |
| case .trance: return "A subgenre of electronic music with repetitive beats and synthesizer melodies." | |
| } | |
| } | |
| } | |
| struct SongModel: Identifiable, Hashable, Sendable { | |
| let id = UUID() | |
| let title: String | |
| let genres: [MusicGenre] | |
| } | |
| enum SearchScopeOption: Hashable, Sendable { | |
| case all | |
| case genre(option: MusicGenre) | |
| var title: String { | |
| switch self { | |
| case .all: | |
| return "All" | |
| case let .genre(option): | |
| return option.rawValue | |
| } | |
| } | |
| } | |
| // MARK: Datasource | |
| protocol SongDataSource: Sendable { | |
| func fetchAllSongs() async throws -> [SongModel] | |
| } | |
| final class SongLocalDataSource: SongDataSource { | |
| func fetchAllSongs() async throws -> [SongModel] { | |
| // Expensive processing | |
| let data = [ | |
| SongModel(title: "Moonlight Sonata", genres: [.classical]), | |
| SongModel(title: "Take Five", genres: [.jazz]), | |
| SongModel(title: "Bohemian Rhapsody", genres: [.rock]), | |
| SongModel(title: "Thriller", genres: [.pop]), | |
| SongModel(title: "Sandstorm", genres: [.electronic]), | |
| SongModel(title: "Lose Yourself", genres: [.hipHop]), | |
| SongModel(title: "Jolene", genres: [.country]), | |
| SongModel(title: "Blowin' in the Wind", genres: [.folk]), | |
| SongModel(title: "The Thrill Is Gone", genres: [.blues]), | |
| SongModel(title: "No Woman, No Cry", genres: [.reggae]), | |
| SongModel(title: "Imagine", genres: [.pop, .rock]), | |
| SongModel(title: "Hey Jude", genres: [.rock]), | |
| SongModel(title: "Blue in Green", genres: [.jazz]), | |
| SongModel(title: "Für Elise", genres: [.classical]), | |
| SongModel(title: "Gangnam Style", genres: [.kpop]), | |
| SongModel(title: "Children", genres: [.electronic, .trance]), | |
| SongModel(title: "Dynamite", genres: [.kpop]), | |
| SongModel(title: "Sandstorm", genres: [.electronic, .trance]), | |
| SongModel(title: "Clair de Lune", genres: [.classical]), | |
| SongModel(title: "Butter", genres: [.kpop]), | |
| SongModel(title: "Mic Drop", genres: [.kpop]), | |
| SongModel(title: "DNA", genres: [.kpop]), | |
| SongModel(title: "Fake Love", genres: [.kpop]), | |
| SongModel(title: "Boy with Luv", genres: [.kpop]), | |
| SongModel(title: "Age of Love", genres: [.electronic, .trance]), | |
| SongModel(title: "Silence", genres: [.electronic, .trance]), | |
| SongModel(title: "For an Angel", genres: [.electronic, .trance]), | |
| SongModel(title: "Adagio for Strings", genres: [.electronic, .trance]), | |
| SongModel(title: "The Four Seasons", genres: [.classical]), | |
| SongModel(title: "Canon in D", genres: [.classical]), | |
| SongModel(title: "Swan Lake", genres: [.classical]), | |
| SongModel(title: "Ode to Joy", genres: [.classical]), | |
| SongModel(title: "Ave Maria", genres: [.classical]) | |
| ] | |
| return data | |
| } | |
| } | |
| // MARK: Repository | |
| protocol MusicRepository: Sendable { | |
| func fetchAllSongs() async throws -> [SongModel] | |
| } | |
| actor MusicRepositoryImpl: MusicRepository { | |
| private let dataSource: SongDataSource | |
| private let cacheKey: Int = 1 | |
| private(set) var cache = [Int: [SongModel]]() | |
| init(dataSource: SongDataSource) { | |
| self.dataSource = dataSource | |
| } | |
| func fetchAllSongs() async throws -> [SongModel] { | |
| let key = cacheKey | |
| if let cachedData = cache[key] { | |
| print("🛟 fetch all data from cache", cachedData.count) | |
| return cachedData | |
| //return .success(cachedData) | |
| //return cachedData | |
| } else { | |
| print("☁️ fetch all data from source") | |
| print("⏳ Whait 5 seconds") | |
| try await Task.sleep(for: .seconds(5)) // Simulate a network delay | |
| let processedData = try! await dataSource.fetchAllSongs() | |
| cache[key] = processedData | |
| print("✅ Fetched data") | |
| return processedData | |
| //return processedData | |
| } | |
| } | |
| } | |
| // MARK: Use Case | |
| enum MusicError: Error { | |
| case cancellationError | |
| } | |
| protocol MusicUseCase: Sendable { | |
| var repository: MusicRepository { get } | |
| func execute(for scope: SearchScopeOption, with searchText: String) async -> Result<[SongModel], Error> | |
| init(repository: MusicRepository) | |
| } | |
| final class MusicUseCaseImpl: MusicUseCase { | |
| let repository: MusicRepository | |
| private let semaphore = AsyncSemaphore(value: 1) | |
| init(repository: MusicRepository) { | |
| self.repository = repository | |
| } | |
| func execute(for scope: SearchScopeOption, with searchText: String) async -> Result<[SongModel], Error> { | |
| print("usecase: MusicUseCase.execute", "for: \(scope)", "with:\(searchText)") | |
| do { | |
| await semaphore.wait() // Wait for the semaphore to become available, limiting concurrent executions to one | |
| print("usecase", "🚥 run race") | |
| print("⏳ Simulating network delay...") | |
| try await Task.sleep(for: .seconds(1)) | |
| let data = try await repository.fetchAllSongs() | |
| let filteredData = filterByScopeAndTerm(from: data, scope: scope, searchText: searchText) | |
| defer { semaphore.signal() } // Release the semaphore to allow other tasks to proceed | |
| print("usecase", "🏁 end race") | |
| return .success(filteredData) | |
| } catch is CancellationError { | |
| print("usecase:", "✋ Cancellation requested: releasing the 🚦 and returning a cancellation error") | |
| defer { semaphore.signal() } // send signal for run other task | |
| return .failure(MusicError.cancellationError) | |
| } catch { | |
| print("usecase:", "❗️An unexpected error occurred: releasing the 🚦 and returning the error") | |
| defer { semaphore.signal() } // send signal for run other task | |
| return .failure(error) | |
| } | |
| } | |
| /// Filters the songs based on scope and search text. | |
| private func filterByScopeAndTerm(from data: [SongModel], scope: SearchScopeOption, searchText: String) -> [SongModel] { | |
| let scopeFiltered = filterByScope(from: data, for: scope) | |
| return filterByTerm(from: scopeFiltered, searchText: searchText) | |
| } | |
| /// Filters songs by scope. | |
| private func filterByScope(from data: [SongModel], for scope: SearchScopeOption) -> [SongModel] { | |
| return data.filter { song in | |
| switch scope { | |
| case .all: | |
| return true | |
| case .genre(let genre): | |
| return song.genres.contains(genre) | |
| } | |
| } | |
| } | |
| /// Filters songs by search term. | |
| private func filterByTerm(from data: [SongModel], searchText: String) -> [SongModel] { | |
| guard !searchText.isEmpty else { return data } | |
| return data.filter { song in | |
| song.title.localizedCaseInsensitiveContains(searchText) || | |
| song.genres.map(\.rawValue).joined(separator: ", ").localizedCaseInsensitiveContains(searchText) | |
| } | |
| } | |
| } | |
| // MARK: Result Types | |
| enum DataListState<T: Sendable>: Sendable { | |
| case firstLoading | |
| case working | |
| case failure(Error) | |
| case success(T) | |
| mutating func startWorking() { | |
| if !isFirstLoading() && !isWorking() { | |
| self = .working | |
| } | |
| } | |
| func isFirstLoading() -> Bool { | |
| if case .firstLoading = self { | |
| return true | |
| } | |
| return false | |
| } | |
| func isWorking() -> Bool { | |
| if case .working = self { | |
| return true | |
| } | |
| return false | |
| } | |
| func isSuccess() -> Bool { | |
| if case .success = self { | |
| return true | |
| } | |
| return false | |
| } | |
| } | |
| // MARK: ViewModel | |
| @Observable | |
| final class MusicViewModel: Sendable { //: @unchecked Sendable | |
| private let repository: MusicRepository | |
| @MainActor private(set) var filteredMusic: DataListState<[SongModel]> = .firstLoading | |
| @MainActor private(set) var availableGenres: [SearchScopeOption] = [.all] // Empieza con "All" | |
| @MainActor private(set) var lastSearchTerm: String = "" | |
| //use case | |
| let getMusicUseCase: MusicUseCase | |
| init(repository: MusicRepository) { | |
| self.repository = repository | |
| self.getMusicUseCase = MusicUseCaseImpl(repository: repository) | |
| } | |
| //@MainActor | |
| func filterSongs(for scope: SearchScopeOption, searchText: String) async throws { | |
| print("vm: filterSongs") | |
| print("🔎 filterSongs", scope.title, searchText) | |
| Task { @MainActor in | |
| filteredMusic.startWorking() | |
| } | |
| let result = await getMusicUseCase.execute(for: scope, with: searchText) | |
| switch(result) { | |
| case .success(let songs): | |
| print("vm:", "👉 candidates \(songs.count)") | |
| // For generate genres scope search | |
| if await availableGenres.count <= 1 { | |
| let genres = Set(songs.flatMap { $0.genres }) | |
| let candidates: [SearchScopeOption] = [.all] + genres.map {.genre(option: $0) }.sorted { $0.title < $1.title } | |
| Task { @MainActor in // Update genres | |
| availableGenres = candidates | |
| } | |
| } | |
| Task { @MainActor in | |
| lastSearchTerm = searchText | |
| filteredMusic = .success(songs) | |
| } | |
| case .failure(let error): | |
| if Task.isCancelled { | |
| //repository.semaphore.signal() | |
| print("vm: ✋ last task was cancelled", error) | |
| } else { | |
| Task { @MainActor in | |
| filteredMusic = .failure(error) | |
| } | |
| } | |
| //filteredMusic = .failure(error) | |
| } | |
| } | |
| } | |
| // MARK: List View | |
| struct MusicListView: View { | |
| @State private var viewModel: MusicViewModel | |
| @State private var isPresented = false | |
| @State private var searchText: String = "" | |
| @State private var selectedScope: SearchScopeOption = .all // Scope seleccionado | |
| init() { | |
| let localDataSource = SongLocalDataSource() | |
| let repository = MusicRepositoryImpl(dataSource: localDataSource) | |
| _viewModel = State(wrappedValue: MusicViewModel(repository: repository)) | |
| } | |
| var body: some View { | |
| @Bindable var viewModel = viewModel //if need bindable data in viewModel | |
| let _ = Self._printChanges() | |
| NavigationStack { | |
| ScrollView(.vertical) { | |
| CustomScopeSearch() | |
| .redacted(reason: viewModel.filteredMusic.isFirstLoading() ? .placeholder : .invalidated) | |
| Group { | |
| switch viewModel.filteredMusic { | |
| case .firstLoading, .working: | |
| // Loading State | |
| ZStack { | |
| // Reserve space matching the scroll view's frame | |
| Spacer().containerRelativeFrame([.horizontal, .vertical]) | |
| ProgressView() | |
| } | |
| case .failure(let error): | |
| // Failure State | |
| ZStack { | |
| // Reserve space matching the scroll view's frame | |
| Spacer().containerRelativeFrame([.horizontal, .vertical]) | |
| ContentUnavailableView( | |
| "Failed to load songs", | |
| systemImage: "exclamationmark.circle.fill", | |
| description: Text("Failed to load songs.\(error.localizedDescription)") | |
| ) | |
| } | |
| case .success(let songs) where songs.isEmpty: | |
| // Handle empty state | |
| ZStack { | |
| // Reserve space matching the scroll view's frame | |
| Spacer().containerRelativeFrame([.horizontal, .vertical]) | |
| if viewModel.lastSearchTerm.isEmpty { | |
| ContentUnavailableView( | |
| "No songs available.", | |
| systemImage: "exclamationmark.circle.fill", | |
| description: Text("No songs available.") | |
| ) | |
| } else { | |
| ContentUnavailableView | |
| .search(text: viewModel.lastSearchTerm) | |
| } | |
| } | |
| case .success(let songs): | |
| renderList(songs) | |
| } | |
| } | |
| .scrollBounceBehavior(.basedOnSize) | |
| } | |
| .searchable(text: $searchText, isPresented: $isPresented, placement: .toolbar, prompt: Text("Search")) | |
| .frame(maxWidth: .infinity, maxHeight: .infinity) | |
| .navigationTitle("Songs") | |
| .animation(.smooth, value: viewModel.filteredMusic.isSuccess()) | |
| .task(id: selectedScope, priority: .background) { | |
| do { | |
| try await performSearch(scope: selectedScope, query: searchText) | |
| } catch { | |
| print("✋ Cancelled last search") | |
| print() | |
| } | |
| } | |
| .task(id: searchText, duration: .milliseconds(500)) { [viewModel] in | |
| //await print("perform search", searchText) | |
| do { | |
| try await viewModel.filterSongs(for: selectedScope, searchText: searchText) | |
| } catch { | |
| print("✋ Cancelled last search") | |
| print() | |
| } | |
| } | |
| } | |
| } | |
| @ViewBuilder | |
| private func CustomScopeSearch() -> some View { | |
| ScrollView(.horizontal, showsIndicators: false) { | |
| HStack { | |
| ForEach(viewModel.availableGenres, id: \.self) { scope in | |
| Button(scope.title) { | |
| selectedScope = scope | |
| } | |
| .modifiers { view in | |
| if selectedScope == scope { | |
| view.buttonStyle(.borderedProminent) | |
| } else { | |
| view.buttonStyle(.bordered) | |
| } | |
| } | |
| .accentColor(selectedScope == scope ? Color.accentColor : .none) | |
| .clipShape(.capsule) | |
| } | |
| } | |
| } | |
| .contentMargins(.horizontal, 16) | |
| } | |
| // @ViewBuilder | |
| // private func renderList(_ items: [SongModel]) -> some View { | |
| // CustomScopeSearch() | |
| // .redacted(reason: viewModel.filteredMusic.isFirstLoading() ? .placeholder : .invalidated) | |
| // List(items, id: \.self) { song in | |
| // VStack(alignment: .leading) { | |
| // Text(song.title) | |
| // .font(.headline) | |
| // Text(song.genres.map(\.rawValue).joined(separator: ", ")) | |
| // .font(.subheadline) | |
| // .foregroundColor(.secondary) | |
| // } | |
| // } | |
| // .scrollDismissesKeyboard(.automatic) | |
| // } | |
| @ViewBuilder | |
| private func renderList(_ items: [SongModel]) -> some View { | |
| ForEach(items, id: \.self) { song in | |
| VStack(alignment: .leading) { | |
| Text(song.title) | |
| .font(.headline) | |
| Text(song.genres.map(\.rawValue).joined(separator: ", ")) | |
| .font(.subheadline) | |
| .foregroundColor(.secondary) | |
| }.frame(maxWidth: .infinity, minHeight: 56, alignment: .leading) | |
| .padding(.horizontal) | |
| Divider() | |
| } | |
| // } | |
| // .scrollDismissesKeyboard(.automatic) | |
| } | |
| } | |
| // MARK: Preview | |
| #Preview { | |
| MusicListView() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment