From 753da1d9cab924adcc7845b4938025d9bc1ead1c Mon Sep 17 00:00:00 2001 From: betterhee Date: Thu, 18 Nov 2021 16:57:39 +0900 Subject: [PATCH] feat: add chart view --- Coins.xcodeproj/project.pbxproj | 8 +- Coins/Models/{Period.swift => Duration.swift} | 4 +- Coins/Networking/CoinServiceApi.swift | 4 +- Coins/Networking/HistoricalCoinRequest.swift | 10 +- Coins/Storyboards/Base.lproj/Main.storyboard | 43 ++++--- .../HistoricalCoinViewController.swift | 109 ++++++++++++++---- Coins/View Models/CoinViewModel.swift | 5 +- .../View Models/HistoricalCoinViewModel.swift | 57 ++++----- 8 files changed, 157 insertions(+), 83 deletions(-) rename Coins/Models/{Period.swift => Duration.swift} (86%) diff --git a/Coins.xcodeproj/project.pbxproj b/Coins.xcodeproj/project.pbxproj index f37d304..cfa2a7f 100644 --- a/Coins.xcodeproj/project.pbxproj +++ b/Coins.xcodeproj/project.pbxproj @@ -29,7 +29,7 @@ 5DD4667A2744797000D1B730 /* HistoricalCoinViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD466792744797000D1B730 /* HistoricalCoinViewModel.swift */; }; 5DD4667C274509F500D1B730 /* HistoricalCoin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD4667B274509F400D1B730 /* HistoricalCoin.swift */; }; 5DD4667E27450C8700D1B730 /* HistoricalCoinRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD4667D27450C8700D1B730 /* HistoricalCoinRequest.swift */; }; - 5DD466822745338800D1B730 /* Period.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD466812745338800D1B730 /* Period.swift */; }; + 5DD466822745338800D1B730 /* Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD466812745338800D1B730 /* Duration.swift */; }; 5DD466852745BF6F00D1B730 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 5DD466842745BF6F00D1B730 /* Charts */; }; 5DD466992745C75800D1B730 /* GraphikRegularItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 5DD4668F2745C73D00D1B730 /* GraphikRegularItalic.otf */; }; 5DD4669A2745C75800D1B730 /* GraphikSemiboldItalic.otf in Resources */ = {isa = PBXBuildFile; fileRef = 5DD4668A2745C73D00D1B730 /* GraphikSemiboldItalic.otf */; }; @@ -68,7 +68,7 @@ 5DD466792744797000D1B730 /* HistoricalCoinViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoricalCoinViewModel.swift; sourceTree = ""; }; 5DD4667B274509F400D1B730 /* HistoricalCoin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoricalCoin.swift; sourceTree = ""; }; 5DD4667D27450C8700D1B730 /* HistoricalCoinRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoricalCoinRequest.swift; sourceTree = ""; }; - 5DD466812745338800D1B730 /* Period.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Period.swift; sourceTree = ""; }; + 5DD466812745338800D1B730 /* Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Duration.swift; sourceTree = ""; }; 5DD4668A2745C73D00D1B730 /* GraphikSemiboldItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = GraphikSemiboldItalic.otf; sourceTree = ""; }; 5DD4668C2745C73D00D1B730 /* GraphikSemibold.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = GraphikSemibold.otf; sourceTree = ""; }; 5DD4668F2745C73D00D1B730 /* GraphikRegularItalic.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = GraphikRegularItalic.otf; sourceTree = ""; }; @@ -130,7 +130,7 @@ children = ( 5DBF026A27423C1900FE6C1D /* Coin.swift */, 5DD4667B274509F400D1B730 /* HistoricalCoin.swift */, - 5DD466812745338800D1B730 /* Period.swift */, + 5DD466812745338800D1B730 /* Duration.swift */, 5DBF02282741FAD600FE6C1D /* Article.swift */, ); path = Models; @@ -331,7 +331,7 @@ 5DBF02692742394500FE6C1D /* CoinRequest.swift in Sources */, 5DBF02162741FA9B00FE6C1D /* ArticlesViewController.swift in Sources */, 5DD4667A2744797000D1B730 /* HistoricalCoinViewModel.swift in Sources */, - 5DD466822745338800D1B730 /* Period.swift in Sources */, + 5DD466822745338800D1B730 /* Duration.swift in Sources */, 5DF277012743841F003C434B /* CoinsViewModel.swift in Sources */, 5DBF02122741FA9B00FE6C1D /* AppDelegate.swift in Sources */, 5DD4667C274509F500D1B730 /* HistoricalCoin.swift in Sources */, diff --git a/Coins/Models/Period.swift b/Coins/Models/Duration.swift similarity index 86% rename from Coins/Models/Period.swift rename to Coins/Models/Duration.swift index 688f215..143ef04 100644 --- a/Coins/Models/Period.swift +++ b/Coins/Models/Duration.swift @@ -7,12 +7,12 @@ import Foundation -enum Period: Int { +enum Duration: Int { case day case week } -extension Period { +extension Duration { var limit: Int { switch self { case .day: diff --git a/Coins/Networking/CoinServiceApi.swift b/Coins/Networking/CoinServiceApi.swift index a9ae519..365ad6d 100644 --- a/Coins/Networking/CoinServiceApi.swift +++ b/Coins/Networking/CoinServiceApi.swift @@ -39,8 +39,8 @@ final class CoinServiceApi { } } - func historicalCoins(from: Coin, period: Period, completion: @escaping (Result<[HistoricalCoin], Error>) -> Void) { - let endpoint = HistoricalCoinRequest.historicalCoin(from: from, to: nil, period: period) + func historicalCoins(from: Coin, duration: Duration, completion: @escaping (Result<[HistoricalCoin], Error>) -> Void) { + let endpoint = HistoricalCoinRequest.historicalCoin(from: from, to: nil, duration: duration) apiRequestLoader.request(with: endpoint) { result in switch result { case .success(let value): diff --git a/Coins/Networking/HistoricalCoinRequest.swift b/Coins/Networking/HistoricalCoinRequest.swift index f16d1bd..7e0c9cc 100644 --- a/Coins/Networking/HistoricalCoinRequest.swift +++ b/Coins/Networking/HistoricalCoinRequest.swift @@ -8,7 +8,7 @@ import Foundation enum HistoricalCoinRequest { - case historicalCoin(from: Coin, to: String?, period: Period) + case historicalCoin(from: Coin, to: String?, duration: Duration) } extension HistoricalCoinRequest: RequestType { @@ -21,9 +21,9 @@ extension HistoricalCoinRequest: RequestType { var path: String { switch self { - case .historicalCoin(_, _, let period) where period == .day: + case .historicalCoin(_, _, let duration) where duration == .day: return "/data/v2/histohour" - case .historicalCoin(_, _, let period) where period == .week: + case .historicalCoin(_, _, let duration) where duration == .week: return "/data/v2/histoday" default: fatalError() @@ -40,11 +40,11 @@ extension HistoricalCoinRequest: RequestType { var parameters: Parameters? { switch self { - case .historicalCoin(let from, let to, let period): + case .historicalCoin(let from, let to, let duration): return [ "fsym": from.name, "tsym": to ?? "USD", - "limit": "\(period.limit)" + "limit": "\(duration.limit)" ] } } diff --git a/Coins/Storyboards/Base.lproj/Main.storyboard b/Coins/Storyboards/Base.lproj/Main.storyboard index 3f4f26d..27fc122 100644 --- a/Coins/Storyboards/Base.lproj/Main.storyboard +++ b/Coins/Storyboards/Base.lproj/Main.storyboard @@ -1,8 +1,8 @@ - + - + @@ -206,10 +206,10 @@ - - + + - + - - - - + + + - + - - + + + - + + + + + - - - + + + + - + @@ -335,5 +341,8 @@ + + + diff --git a/Coins/View Controllers/HistoricalCoinViewController.swift b/Coins/View Controllers/HistoricalCoinViewController.swift index 9cdecfd..4cf269f 100644 --- a/Coins/View Controllers/HistoricalCoinViewController.swift +++ b/Coins/View Controllers/HistoricalCoinViewController.swift @@ -9,48 +9,111 @@ import UIKit import Charts final class HistoricalCoinViewController: UIViewController { - + // MARK: - Properties - + var viewModel: HistoricalCoinViewModel! - + @IBOutlet weak var coinLabel: UILabel! @IBOutlet weak var priceLabel: UILabel! @IBOutlet weak var chartView: LineChartView! - @IBOutlet weak var periodSegmentedControl: UISegmentedControl! - + @IBOutlet weak var durationSegmentedControl: UISegmentedControl! + // MARK: - View Life Cycle - + override func viewDidLoad() { super.viewDidLoad() - + setupViewModel() setupView() } - + + // MARK: Setup + private func setupViewModel() { - viewModel.didReceiveHistoricalCoin = { historicalCoins in - + viewModel.didReceiveHistoricalCoin = { chartDatas, duration in + let entries = chartDatas.map { ChartDataEntry(x: $0.0, y: $0.1)} + let dataSet = LineChartDataSet(entries: entries) + dataSet.mode = .horizontalBezier + dataSet.colors = [UIColor.systemBlue] + dataSet.drawCirclesEnabled = false + dataSet.drawCircleHoleEnabled = false + dataSet.drawValuesEnabled = false + dataSet.drawHorizontalHighlightIndicatorEnabled = false + + let startColor = UIColor.systemBlue + let endColor = UIColor(white: 1, alpha: 0.3) + let gradientColor = [startColor.cgColor, endColor.cgColor] as CFArray + let colorLocations: [CGFloat] = [1.0, 0.0] + let gradient = CGGradient.init(colorsSpace: CGColorSpaceCreateDeviceRGB(), colors: gradientColor, locations: colorLocations) + dataSet.fill = RadialGradientFill(gradient: gradient!) + dataSet.drawFilledEnabled = true + + let data = LineChartData(dataSet: dataSet) + self.chartView.data = data + } + viewModel.didSelectChartValue = { price in + self.priceLabel.text = price } - viewModel.fetchHistoricalCoin() } - - + + private func setupView() { - periodSegmentedControl.backgroundColor = .clear - periodSegmentedControl.selectedSegmentTintColor = .systemBlue + coinLabel.text = viewModel.title + setupChartView() + setupDurationSegmentedControl() + } + + private func setupDurationSegmentedControl() { + durationSegmentedControl.backgroundColor = .white + durationSegmentedControl.selectedSegmentTintColor = .systemBlue let normalTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.systemGray] - periodSegmentedControl.setTitleTextAttributes(normalTitleTextAttributes, for:.normal) + durationSegmentedControl.setTitleTextAttributes(normalTitleTextAttributes, for:.normal) let selectedTitleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white] - periodSegmentedControl.setTitleTextAttributes(selectedTitleTextAttributes, for:.selected) - - coinLabel.text = viewModel.title + durationSegmentedControl.setTitleTextAttributes(selectedTitleTextAttributes, for:.selected) + durationSegmentedControl.sendActions(for: .valueChanged) } + + private func setupChartView() { + let xAxis = chartView.xAxis + xAxis.drawGridLinesEnabled = false + xAxis.drawAxisLineEnabled = true + xAxis.drawLabelsEnabled = true + xAxis.labelPosition = .bottom + + let leftYAxis = chartView.leftAxis + leftYAxis.drawGridLinesEnabled = false + leftYAxis.drawAxisLineEnabled = false + leftYAxis.drawLabelsEnabled = false + + let rightYAxis = chartView.rightAxis + rightYAxis.drawGridLinesEnabled = false + rightYAxis.drawAxisLineEnabled = false + rightYAxis.drawLabelsEnabled = false + + chartView.doubleTapToZoomEnabled = false + chartView.legend.enabled = false + chartView.delegate = self + } + + // MARK: - Actions + + @IBAction func durationSegmentedControlDidTap(_ sender: UISegmentedControl) { + viewModel.selectDuration(at: sender.selectedSegmentIndex) + } + +} +// MARK: - ChartViewDelegate - @IBAction func periodSegmentedControlDidTap(_ sender: UISegmentedControl) { - let period: Period = Period(rawValue: sender.selectedSegmentIndex) ?? .day - viewModel.selectedPeriod = period +extension HistoricalCoinViewController: ChartViewDelegate { + + public func chartValueSelected(_ chartView: ChartViewBase, entry: ChartDataEntry, highlight: Highlight) { + viewModel.selectChartValue(entry.y) } - + + func chartValueNothingSelected(_ chartView: ChartViewBase) { + print(#function) + } + } diff --git a/Coins/View Models/CoinViewModel.swift b/Coins/View Models/CoinViewModel.swift index 42ee2a3..c8caf20 100644 --- a/Coins/View Models/CoinViewModel.swift +++ b/Coins/View Models/CoinViewModel.swift @@ -24,10 +24,7 @@ extension CoinViewModel { } var price: String { - let price = coin.price as NSNumber - let formatter = NumberFormatter() - formatter.numberStyle = .currency - return formatter.string(from: price)! + "USD \(coin.price)" } var changePercent: String { diff --git a/Coins/View Models/HistoricalCoinViewModel.swift b/Coins/View Models/HistoricalCoinViewModel.swift index 0a2678f..627cd49 100644 --- a/Coins/View Models/HistoricalCoinViewModel.swift +++ b/Coins/View Models/HistoricalCoinViewModel.swift @@ -8,46 +8,51 @@ import Foundation final class HistoricalCoinViewModel { - - var didReceiveHistoricalCoin: (([HistoricalCoin]) -> Void)? - - private var coin: Coin + + var didReceiveHistoricalCoin: (([(time: TimeInterval, price: Double)], Int) -> Void)? + var didSelectChartValue: ((String) -> Void)? + + private let coin: Coin private let service: CoinServiceApi - private var historicalCoin: [HistoricalCoin] = [] { - didSet { - didReceiveHistoricalCoin?(self.historicalCoin) - } - } - + init(coin: Coin, service: CoinServiceApi = CoinServiceApi()) { self.coin = coin self.service = service } - - var selectedPeriod: Period = .day { - didSet { - fetchHistoricalCoin() - } - } - + } extension HistoricalCoinViewModel { - - func fetchHistoricalCoin() { - service.historicalCoins(from: coin, period: selectedPeriod) { result in + + var title: String { + coin.name + } + + func selectDuration(at index: Int) { + DispatchQueue.global(qos: .userInitiated).async { + let duration: Duration = Duration(rawValue: index) ?? .day + self.fetchHistoricalCoin(duration: duration) + } + } + + func selectChartValue(_ value: Double) { + didSelectChartValue?("USD \(value)") + } + + func fetchHistoricalCoin(duration: Duration) { + service.historicalCoins(from: coin, duration: duration) { result in switch result { case .success(let value): - self.historicalCoin = value + let duration = duration.rawValue + let chartData = value.map { ($0.time, $0.price) } + DispatchQueue.main.async { + self.didReceiveHistoricalCoin?(chartData, duration) + } case .failure: break } } } - - var title: String { - coin.name - } - + }