Build Swift Charts Stocks App Part 2 - My Ticker Symbols List & Search Tickers - SwiftUI iOS 16 App
persistent data
: 로컬 데이터 구현import XCAStocksAPI
import SwiftUI
@MainActor
class AppViewModel: ObservableObject {
@Published var tickers: [Ticker] = [] {
didSet {
saveTickers()
}
}
var emptyTickersText = "Search & add symbol to see stock quotes"
var titleText = "Stocks"
var attributionText = "Powered by Yahoo! finance API"
@Published var subtitleText: String
let tickerListRepository: TickerListRepository
private let subtitleDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "d MMM"
return formatter
}()
func openYahooFinance() {
guard
let url = URL(string: "https://finance.yahoo.com"),
UIApplication.shared.canOpenURL(url) else { return }
UIApplication.shared.open(url)
}
init(repository: TickerListRepository = TickerPlistRepository()) {
self.tickerListRepository = repository
self.subtitleText = subtitleDateFormatter.string(from: Date())
loadTickers()
}
private func loadTickers() {
Task { [weak self] in
guard let self = self else { return }
do {
self.tickers = try await tickerListRepository.load()
} catch {
print(error.localizedDescription)
self.tickers = []
}
}
}
private func saveTickers() {
Task { [weak self] in
guard let self = self else { return }
do {
try await tickerListRepository.save(self.tickers)
} catch {
print(error.localizedDescription)
}
}
}
func removeTickers(atOffsets offsets: IndexSet) {
tickers.remove(atOffsets: offsets)
}
func isAddedToMyTickers(ticker: Ticker) -> Bool {
tickers.first { $0.symbol == ticker.symbol } != nil
}
func toggleTicker(_ ticker: Ticker) {
if isAddedToMyTickers(ticker: ticker) {
removeFromMyTickers(ticker)
} else {
addToMyTickers(ticker)
}
}
private func addToMyTickers(_ ticker: Ticker) {
tickers.append(ticker)
}
private func removeFromMyTickers(_ ticker: Ticker) {
guard let index = tickers.firstIndex(where: { $0.symbol == ticker.symbol }) else { return }
tickers.remove(at: index)
}
}
@MainActor
를 통해 스레드 안전성 보장tickers
변수는 @Published
프로토콜을 따르는 데이터 퍼블리셔didSet
을 통해 해당 값 변경이 되었을 때 saveTickers()
메소드가 자동으로 호출Task
, async await
를 따르는 비동기 처리 방법import SwiftUI
import XCAStocksAPI
@MainActor
class QuotesViewModel: ObservableObject {
@Published var quotesDict:[String: Quote] = [:]
private let stocksAPI: StocksAPI
init(stocksAPI: StocksAPI = XCAStocksAPI()) {
self.stocksAPI = stocksAPI
}
func fetchQuotes(tickers: [Ticker]) async {
guard !tickers.isEmpty else { return }
do {
let symbols = tickers.map({ $0.symbol }).joined(separator: ",")
let quotes = try await stocksAPI.fetchQuotes(symbols: symbols)
var dict = [String: Quote]()
quotes.forEach{ dict[$0.symbol] = $0 }
self.quotesDict = dict
} catch {
print(error.localizedDescription)
}
}
func priceForTicker(_ ticker: Ticker) -> PriceChange? {
guard
let quote = quotesDict[ticker.symbol],
let price = quote.regularPriceText,
let change = quote.regularDiffText else { return nil }
return (price, change)
}
}
Quote
를 리턴fetchQuotes
를 통해 주어진 Ticker
에 대한 모든 정보를 업데이트import Combine
import SwiftUI
import XCAStocksAPI
@MainActor
class SearchViewModel: ObservableObject {
@Published var query: String = ""
@Published var phase: FetchPhase<[Ticker]> = .initial
private var trimmedQuery: String {
query.trimmingCharacters(in: .whitespacesAndNewlines)
}
private var cancellables = Set<AnyCancellable>()
var tickers: [Ticker] {
phase.value ?? []
}
var error: Error? {
phase.error
}
var isSearching: Bool {
!trimmedQuery.isEmpty
}
var emptyListText: String {
"Symbols not fount for\n\"\(query)\""
}
private let stocksAPI: StocksAPI
init(query: String = "", stocksAPI: StocksAPI = XCAStocksAPI() ) {
self.query = query
self.stocksAPI = stocksAPI
setupObserving()
}
private func setupObserving() {
$query
.debounce(for: .seconds(0.5), scheduler: RunLoop.main)
.sink { _ in
Task { [weak self] in
await self?.searchTickers()
}
}
.store(in: &cancellables)
$query
.filter({ $0.isEmpty })
.sink { [weak self] _ in
self?.phase = .initial
}
.store(in: &cancellables)
}
func searchTickers() async {
let searchQuery = trimmedQuery
guard !searchQuery.isEmpty else { return }
phase = .fetching
do {
let tickers = try await stocksAPI.searchTickers(query: searchQuery, isEquityTypeOnly: true)
guard searchQuery == trimmedQuery else { return }
if tickers.isEmpty {
phase = .empty
} else {
phase = .success(tickers)
}
} catch {
guard searchQuery == trimmedQuery else { return }
phase = .failure(error)
}
}
}
phase
를 통해 검색 전, 검색 중, 검색 성공, 실패 등 단계를 이넘으로 관리setupObserving
을 통해 이니셜라이즈될 때 해당 쿼리문을 지속적으로 관찰, 특정 시간동안 업데이트가 멈추었다면 자동으로 해당 쿼리문을 통한 데이터 검색import SwiftUI
import XCAStocksAPI
@MainActor
struct SearchView: View {
@EnvironmentObject private var appViewModel: AppViewModel
@StateObject var quotesViewModel = QuotesViewModel()
@ObservedObject var searchViewModel: SearchViewModel
var body: some View {
List(searchViewModel.tickers) { ticker in
TickerListRowView(data: .init(symbol: ticker.symbol, name: ticker.shortname, price: quotesViewModel.priceForTicker(ticker), type: .search(isSaved: appViewModel.isAddedToMyTickers(ticker: ticker), onButtonDidTap: {
Task { @MainActor in
appViewModel.toggleTicker(ticker)
}
})))
}
.listStyle(.plain)
.refreshable {
await quotesViewModel.fetchQuotes(tickers: searchViewModel.tickers)
}
.task(id: searchViewModel.tickers, {
await quotesViewModel.fetchQuotes(tickers: searchViewModel.tickers)
})
.overlay {
listSearchOverlay
}
}
@ViewBuilder
private var listSearchOverlay: some View {
switch searchViewModel.phase {
case .failure(let error):
ErrorStateView(error: error.localizedDescription) {
Task {
await searchViewModel.searchTickers()
}
}
case .empty:
EmptyStateView(text: searchViewModel.emptyListText)
case .fetching:
LoadingStateView()
default: EmptyView()
}
}
}
import Foundation
import XCAStocksAPI
protocol TickerListRepository {
func save(_ current: [Ticker]) async throws
func load() async throws -> [Ticker]
}
class TickerPlistRepository: TickerListRepository {
private var saved: [Ticker]?
private let fileName: String
private var url: URL {
guard let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?
.appending(component: "\(fileName).plist") else { fatalError() }
return url
}
init(fileName: String = "tickers") {
self.fileName = fileName
}
func save(_ current: [Ticker]) async throws {
guard saved != current else { return }
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let data = try encoder.encode(current)
try data.write(to: url, options: [.atomic])
self.saved = current
}
func load() async throws -> [Ticker] {
let data = try Data(contentsOf: url)
let decoder = PropertyListDecoder()
let current = try decoder.decode([Ticker].self, from: data)
self.saved = current
return current
}
}
Ticker
목록을 로드 및 세이브하기 위한 레포지터리PropertyListEncoder
, PropertyListDecoder
를 통해 데이터 인코딩 및 디코딩을 통해 해당 [Ticker]
데이터 핸들링