[SwiftUI] ChartStocksClone: Search Tickers

Junyoung Park·2022년 12월 14일
0

SwiftUI

목록 보기
127/136
post-thumbnail
post-custom-banner

Build Swift Charts Stocks App Part 2 - My Ticker Symbols List & Search Tickers - SwiftUI iOS 16 App

ChartStocksClone: Search Tickers

구현 목표

  • API 데이터 검색을 통한 특정 데이터 저장 및 관리

구현 태스크

  • 메인 리스트 뷰 구현
  • 검색 뷰 구현
  • 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를 리턴
  • 파라미터로 건네받은 API를 통해 이니셜라이즈
  • 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)
        }
    }
}
  • 쿼리문을 통해 API 검색 담당 및 리턴받은 데이터를 핸들링
  • 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()
        }
    }
}
  • 검색 결과를 통해 주어진 리스트 UI 구성
  • 검색 결과에 해당하는 셀 버튼 클릭 시 해당 데이터가 자동으로 추가/삭제 토글
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 목록을 로드 및 세이브하기 위한 레포지터리
  • 로컬 문서 내 디렉터리를 통한 URL
  • PropertyListEncoder, PropertyListDecoder를 통해 데이터 인코딩 및 디코딩을 통해 해당 [Ticker] 데이터 핸들링

구현 화면

profile
JUST DO IT
post-custom-banner

0개의 댓글