From 283e9378a46c4a426bfd8a041580b06df0d77bae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Wed, 29 Oct 2025 23:59:30 +0900 Subject: [PATCH 1/7] =?UTF-8?q?design:=20Top5=EB=A5=BC=20=ED=91=9C?= =?UTF-8?q?=ED=98=84=ED=95=98=EB=8A=94=20=EB=B7=B0=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/View/TopCoinListView.swift | 112 ++++++++++++++++++ .../ViewModel/TopCoinListViewModel.swift | 63 ++++++++++ 2 files changed, 175 insertions(+) create mode 100644 AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift create mode 100644 AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift diff --git a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift new file mode 100644 index 00000000..9739f517 --- /dev/null +++ b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift @@ -0,0 +1,112 @@ +// +// TopCoinListView.swift +// iCo +// +// Created by 백현진 on 10/28/25. +// + +import SwiftUI + +struct TopCoinListView: View { + @StateObject private var viewModel = TopCoinListViewModel() + + var body: some View { + VStack(alignment: .leading ,spacing: 16) { + HStack { + Image(systemName: "bitcoinsign.bank.building") + .foregroundStyle(.iCoAccent) + + Text("주목할 만한 코인 TOP5") + } + .font(.ico16B) + .padding(.horizontal, 22) + .padding(.top, 20) + + Picker("Segment", selection: $viewModel.selectedSegment) { + ForEach(TopCoinListViewModel.SegmentType.allCases) { segment in + Text(segment.rawValue).tag(segment) + } + } + .pickerStyle(.segmented) + .padding(.horizontal) + + if viewModel.isLoading { + DefaultProgressView(status: .loading, message: "시세 불러오는중") + } else { + VStack { + ForEach(Array(viewModel.topCoins.enumerated()), id: \.element.id) { index, coin in + HStack { + Text("\(index + 1)") + .font(.ico14B) + .foregroundColor(.iCoAccent) + .padding(.trailing, 16) + + CachedAsyncImage(resource: .symbol(coin.coinSymbol)) { + Text(String(coin.coinSymbol.prefix(1))) + .font(.ico15Sb) + .foregroundStyle(.iCoAccent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.iCoBackgroundAccent) + .overlay( + Circle().strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + .contentShape(Circle()) + .padding(.trailing, 8) + + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.koreanName(for: coin.id)) + .font(.ico15) + + if viewModel.selectedSegment == .volume { + Text(coin.formatedVolume) + .font(.ico12) + .foregroundColor(.iCoLabelSecondary) + } else { + Text(coin.formatedRate) + .font(.ico12) + .foregroundColor( + coin.change == .rise ? .iCoPositive : + (coin.change == .fall ? .iCoNegative : .iCoNeutral) + ) + } + } + + Spacer() + + if let values = viewModel.candles[coin.id] { + LineChartView( + values: values, + lineColor: coin.change == .fall ? .iCoNegative : .iCoPositive + ) + .frame(width: 100, height: 40) + } else { + ProgressView() + .frame(width: 100, height: 40) + } + } + .padding(.vertical, 16) + .padding(.horizontal, 22) + } + } + .background(.clear) + } + } + .background(.iCoBackground) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .task { + await viewModel.fetchData() + } + } +} + +#Preview { + TopCoinListView() +} diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift new file mode 100644 index 00000000..bb5bd976 --- /dev/null +++ b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift @@ -0,0 +1,63 @@ +// +// TopCoinListViewModel.swift +// iCo +// +// Created by 백현진 on 10/28/25. +// + +import SwiftUI + +@MainActor +final class TopCoinListViewModel: ObservableObject { + private let api = UpBitAPIService() + + @Published var tickers: [TickerValue] = [] + @Published var coins: [CoinDTO] = [] + @Published var candles: [String: [Double]] = [:] + @Published var isLoading = false + @Published var selectedSegment: SegmentType = .volume + + enum SegmentType: String, CaseIterable, Identifiable { + case volume = "거래대금 Top5" + case rate = "상승률 Top5" + var id: String { rawValue } + } + + func fetchData() async { + isLoading = true + defer { isLoading = false } + + do { + let coins = try await api.fetchMarkets() + self.coins = coins + + let tickers = try await api.fetchTicker(by: "KRW") + self.tickers = tickers + + let topMarkets = tickers + .sorted { $0.volume > $1.volume } + .prefix(10) + .map { $0.id } + + for id in topMarkets { + let candles = try await api.fetchCandles(id: id, count: 10) + self.candles[id] = candles.map { $0.tradePrice }.reversed() + } + } catch { + print("Fetch Error:", error) + } + } + + var topCoins: [TickerValue] { + switch selectedSegment { + case .volume: + return Array(tickers.sorted { $0.volume > $1.volume }.prefix(5)) + case .rate: + return Array(tickers.sorted { $0.signedRate > $1.signedRate }.prefix(5)) + } + } + + func koreanName(for id: String) -> String { + coins.first(where: { $0.coinID == id })?.koreanName ?? id + } +} From d606a3a360bc9fb786de08b05f65f7668fc9b796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Thu, 30 Oct 2025 03:01:54 +0900 Subject: [PATCH 2/7] feat: upbit fetch methods --- .../Dashboard/View/DashboardView.swift | 1 + .../ViewModel/TopCoinListViewModel.swift | 27 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/AIProject/iCo/Features/Dashboard/View/DashboardView.swift b/AIProject/iCo/Features/Dashboard/View/DashboardView.swift index a4c3eff7..ad076ee0 100644 --- a/AIProject/iCo/Features/Dashboard/View/DashboardView.swift +++ b/AIProject/iCo/Features/Dashboard/View/DashboardView.swift @@ -50,6 +50,7 @@ struct DashboardView: View { VStack { RecommendCoinView(headerHeight: dynamicHeaderHeight) AIBriefingView() + TopCoinListView() } .padding(.top, topInset) .background { diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift index bb5bd976..46ce1d3a 100644 --- a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift +++ b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift @@ -34,14 +34,31 @@ final class TopCoinListViewModel: ObservableObject { let tickers = try await api.fetchTicker(by: "KRW") self.tickers = tickers - let topMarkets = tickers + let topVolumeIDs = tickers .sorted { $0.volume > $1.volume } - .prefix(10) + .prefix(5) .map { $0.id } - for id in topMarkets { - let candles = try await api.fetchCandles(id: id, count: 10) - self.candles[id] = candles.map { $0.tradePrice }.reversed() + let topRateIDs = tickers + .sorted { $0.signedRate > $1.signedRate } + .prefix(5) + .map { $0.id } + + let targetIDs = Array(Set(topVolumeIDs + topRateIDs)) + + await withTaskGroup(of: Void.self) { group in + for id in targetIDs { + group.addTask { + do { + let candleData = try await self.api.fetchCandles(id: id, count: 10) + await MainActor.run { + self.candles[id] = candleData.map { $0.tradePrice }.reversed() + } + } catch { + print("Candle fetch failed for \(id):", error) + } + } + } } } catch { print("Fetch Error:", error) From 814964a1c62076206220441403ffa9f486803bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Thu, 30 Oct 2025 16:04:13 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EA=B3=B5=ED=86=B5=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=EC=84=9C=20segment?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/View/TopCoinListView.swift | 21 ++++++++++++------- .../ViewModel/TopCoinListViewModel.swift | 4 ++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift index 9739f517..309491de 100644 --- a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift +++ b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift @@ -9,6 +9,7 @@ import SwiftUI struct TopCoinListView: View { @StateObject private var viewModel = TopCoinListViewModel() + @State private var selectedTab = 0 var body: some View { VStack(alignment: .leading ,spacing: 16) { @@ -21,13 +22,19 @@ struct TopCoinListView: View { .font(.ico16B) .padding(.horizontal, 22) .padding(.top, 20) - - Picker("Segment", selection: $viewModel.selectedSegment) { - ForEach(TopCoinListViewModel.SegmentType.allCases) { segment in - Text(segment.rawValue).tag(segment) - } - } - .pickerStyle(.segmented) + + SegmentedControlView( + selection: Binding( + get: { + viewModel.selectedSegment.index + }, + set: { newIndex in + viewModel.selectedSegment = TopCoinListViewModel.SegmentType.allCases[newIndex] + } + ), + tabTitles: TopCoinListViewModel.SegmentType.allCases.map { $0.rawValue }, + width: .infinity + ) .padding(.horizontal) if viewModel.isLoading { diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift index 46ce1d3a..b78a1ec4 100644 --- a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift +++ b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift @@ -21,6 +21,10 @@ final class TopCoinListViewModel: ObservableObject { case volume = "거래대금 Top5" case rate = "상승률 Top5" var id: String { rawValue } + + var index: Int { + Self.allCases.firstIndex(of: self) ?? 0 + } } func fetchData() async { From 4465014fc06782083d703c94cc0c02a41df2eff0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Thu, 30 Oct 2025 16:04:31 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20lineChartView=20path=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Chart=EB=A1=9C=20=EA=B7=B8=EB=A6=AC=EB=8A=94?= =?UTF-8?q?=EA=B2=83=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iCo/Features/Base/LineChartView.swift | 85 +++++++------------ 1 file changed, 32 insertions(+), 53 deletions(-) diff --git a/AIProject/iCo/Features/Base/LineChartView.swift b/AIProject/iCo/Features/Base/LineChartView.swift index 71898151..7bfab1d4 100644 --- a/AIProject/iCo/Features/Base/LineChartView.swift +++ b/AIProject/iCo/Features/Base/LineChartView.swift @@ -6,75 +6,54 @@ // import SwiftUI +import Charts /// 공통으로 사용할 수 있는 라인 차트(미니 차트) 컴포넌트 /// /// - Parameters: /// - values: 차트에 표시할 값 목록 (예: 가격 데이터) /// - lineColor: 차트 선 색상 (기본값은 `Color.iCoAccent`) -/// - showsLastDot: 마지막 지점(최근 데이터)에 점을 표시할지 여부 (`true` 시 표시) /// - lineWidth: 선의 두께 (기본값: `2`) struct LineChartView: View { - /// 차트에 표시할 값 목록 (예: 가격 데이터) let values: [Double] - - /// 차트 선 색상 (기본값: aiCoAccent) var lineColor: Color = .iCoAccent - - /// 마지막 지점 표시 여부 - var showsLastDot: Bool = true - - /// 선 두께 var lineWidth: CGFloat = 2 - - /// 내부 계산용 정규화된 값 - private var normalizedValues: [CGFloat] { - guard values.count > 1 else { return [] } - guard let min = values.min(), let max = values.max(), min != max else { - return Array(repeating: 0.5, count: values.count) - } - return values.map { CGFloat(($0 - min) / (max - min)) } - } var body: some View { - GeometryReader { geo in - if normalizedValues.isEmpty { - // 값이 없을 때 — 가운데 회색 선 표시 - Path { path in - let midY = geo.size.height / 2 - path.move(to: CGPoint(x: 0, y: midY)) - path.addLine(to: CGPoint(x: geo.size.width, y: midY)) - } - .stroke(Color.gray.opacity(0.4), lineWidth: 1) - } else { - ZStack { - // 스파크라인 - Path { path in - for (index, value) in normalizedValues.enumerated() { - let x = geo.size.width * CGFloat(index) / CGFloat(normalizedValues.count - 1) - let y = geo.size.height * (1 - value) - if index == 0 { - path.move(to: CGPoint(x: x, y: y)) - } else { - path.addLine(to: CGPoint(x: x, y: y)) - } - } - } - .stroke(lineColor, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)) - - // 마지막 점 - if showsLastDot, let last = normalizedValues.last { - let x = geo.size.width - let y = geo.size.height * (1 - last) + if values.isEmpty { + Rectangle() + .fill(Color.gray.opacity(0.4)) + .frame(height: 1) + .frame(maxHeight: .infinity, alignment: .center) + } else { + let normalizedData = values.map { ($0 / (values.first ?? 1.0)) - 1.0 } - Circle() - .fill(lineColor) - .frame(width: 6, height: 6) - .position(x: x, y: y) - } + Chart { + ForEach(Array(normalizedData.enumerated()), id: \.offset) { + index, + value in + LineMark( + x: .value("Index", Double(index)), + y: .value("Change", value) + ) + .foregroundStyle(lineColor) + .interpolationMethod(.catmullRom) + + AreaMark( + x: .value("Index", Double(index)), + y: .value("Change", value) + ) + .foregroundStyle( + LinearGradient( + colors: [lineColor.opacity(0.2), .clear], + startPoint: .top, + endPoint: .bottom + ) + ) } } + .chartXAxis(.hidden) + .chartYAxis(.hidden) } } } - From f2624b4cca25885d7b52d561e4eb808a78631d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Sat, 1 Nov 2025 20:02:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=BD=94=EC=9D=B8=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=EC=84=9C=20=ED=84=B0=EC=B9=98?= =?UTF-8?q?=EC=8B=9C=20CoinDetailView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../iCo/Features/Base/LineChartView.swift | 1 + .../Dashboard/View/TopCoinListView.swift | 143 +++++++++++------- 2 files changed, 86 insertions(+), 58 deletions(-) diff --git a/AIProject/iCo/Features/Base/LineChartView.swift b/AIProject/iCo/Features/Base/LineChartView.swift index 7bfab1d4..e7136a2c 100644 --- a/AIProject/iCo/Features/Base/LineChartView.swift +++ b/AIProject/iCo/Features/Base/LineChartView.swift @@ -50,6 +50,7 @@ struct LineChartView: View { endPoint: .bottom ) ) + .interpolationMethod(.catmullRom) } } .chartXAxis(.hidden) diff --git a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift index 309491de..fee1bc4b 100644 --- a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift +++ b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift @@ -40,77 +40,104 @@ struct TopCoinListView: View { if viewModel.isLoading { DefaultProgressView(status: .loading, message: "시세 불러오는중") } else { - VStack { - ForEach(Array(viewModel.topCoins.enumerated()), id: \.element.id) { index, coin in - HStack { - Text("\(index + 1)") - .font(.ico14B) - .foregroundColor(.iCoAccent) - .padding(.trailing, 16) + TopCoinListSection(viewModel: viewModel) + } + } + .background(.iCoBackground) + .clipShape(RoundedRectangle(cornerRadius: 20)) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + .padding(.horizontal, 16) + .task { + await viewModel.fetchData() + } + } +} - CachedAsyncImage(resource: .symbol(coin.coinSymbol)) { - Text(String(coin.coinSymbol.prefix(1))) - .font(.ico15Sb) - .foregroundStyle(.iCoAccent) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.iCoBackgroundAccent) - .overlay( - Circle().strokeBorder(.defaultGradient, lineWidth: 0.5) - ) +struct TopCoinListSection: View { + @ObservedObject var viewModel: TopCoinListViewModel + @State private var showNewBadge = false + @Environment(CoinStore.self) var coinStore + + var body: some View { + VStack { + ForEach(Array(viewModel.topCoins.enumerated()), id: \.element.id) { index, coin in + NavigationLink { + if let meta = coinStore.coins[coin.id] { + VStack(spacing: 0) { + HeaderView( + heading: meta.koreanName, + coinSymbol: meta.coinSymbol, + showBackButton: true, + showNewBadge: showNewBadge + ) + .toolbar(.hidden, for: .navigationBar) + + CoinDetailView(coin: meta) { isNew in + showNewBadge = isNew } - .frame(width: 40, height: 40) - .clipShape(Circle()) - .contentShape(Circle()) - .padding(.trailing, 8) + .id(coin.id) + } + } + } label: { + HStack { + Text("\(index + 1)") + .font(.ico14B) + .foregroundColor(.iCoAccent) + .padding(.trailing, 16) - VStack(alignment: .leading, spacing: 8) { - Text(viewModel.koreanName(for: coin.id)) - .font(.ico15) + CachedAsyncImage(resource: .symbol(coin.coinSymbol)) { + Text(String(coin.coinSymbol.prefix(1))) + .font(.ico15Sb) + .foregroundStyle(.iCoAccent) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.iCoBackgroundAccent) + .overlay( + Circle().strokeBorder(.defaultGradient, lineWidth: 0.5) + ) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + .padding(.trailing, 8) - if viewModel.selectedSegment == .volume { - Text(coin.formatedVolume) - .font(.ico12) - .foregroundColor(.iCoLabelSecondary) - } else { - Text(coin.formatedRate) - .font(.ico12) - .foregroundColor( - coin.change == .rise ? .iCoPositive : - (coin.change == .fall ? .iCoNegative : .iCoNeutral) - ) - } + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.koreanName(for: coin.id)) + .font(.ico15) + if viewModel.selectedSegment == .volume { + Text(coin.formatedVolume) + .font(.ico12) + .foregroundColor(.iCoLabelSecondary) + } else { + Text(coin.formatedRate) + .font(.ico12) + .foregroundColor( + coin.change == .rise ? .iCoPositive : + (coin.change == .fall ? .iCoNegative : .iCoNeutral) + ) } + } - Spacer() + Spacer() - if let values = viewModel.candles[coin.id] { - LineChartView( - values: values, - lineColor: coin.change == .fall ? .iCoNegative : .iCoPositive - ) + if let values = viewModel.candles[coin.id] { + LineChartView( + values: values, + lineColor: coin.change == .fall ? .iCoNegative : .iCoPositive + ) + .frame(width: 100, height: 40) + } else { + ProgressView() .frame(width: 100, height: 40) - } else { - ProgressView() - .frame(width: 100, height: 40) - } } - .padding(.vertical, 16) - .padding(.horizontal, 22) } + .padding(.vertical, 16) + .padding(.horizontal, 22) } - .background(.clear) + .buttonStyle(.plain) } } - .background(.iCoBackground) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .overlay( - RoundedRectangle(cornerRadius: 20) - .strokeBorder(.defaultGradient, lineWidth: 0.5) - ) - .padding(.horizontal, 16) - .task { - await viewModel.fetchData() - } } } From bd9147009f72dcc5211f597cbc86c99c604e11d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Sat, 1 Nov 2025 21:05:10 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20dashboard=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AIProject/iCo/Features/Dashboard/View/DashboardView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/AIProject/iCo/Features/Dashboard/View/DashboardView.swift b/AIProject/iCo/Features/Dashboard/View/DashboardView.swift index ad076ee0..a4c3eff7 100644 --- a/AIProject/iCo/Features/Dashboard/View/DashboardView.swift +++ b/AIProject/iCo/Features/Dashboard/View/DashboardView.swift @@ -50,7 +50,6 @@ struct DashboardView: View { VStack { RecommendCoinView(headerHeight: dynamicHeaderHeight) AIBriefingView() - TopCoinListView() } .padding(.top, topInset) .background { From b874bacd623c4ddc1d1e150f36d41ed9c47e6fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=92=E1=85=A7=E1=86=AB=E1=84=8C=E1=85=B5=E1=86=AB?= Date: Sat, 1 Nov 2025 21:21:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20fetchTask=20cancel=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Dashboard/View/TopCoinListView.swift | 9 +- .../ViewModel/TopCoinListViewModel.swift | 82 ++++++++++++------- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift index fee1bc4b..e37ac5a9 100644 --- a/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift +++ b/AIProject/iCo/Features/Dashboard/View/TopCoinListView.swift @@ -50,8 +50,13 @@ struct TopCoinListView: View { .strokeBorder(.defaultGradient, lineWidth: 0.5) ) .padding(.horizontal, 16) - .task { - await viewModel.fetchData() + .onAppear { + Task { + await viewModel.fetchData() + } + } + .onDisappear { + viewModel.cancelFetch() } } } diff --git a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift index b78a1ec4..13fc0141 100644 --- a/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift +++ b/AIProject/iCo/Features/Dashboard/ViewModel/TopCoinListViewModel.swift @@ -17,6 +17,8 @@ final class TopCoinListViewModel: ObservableObject { @Published var isLoading = false @Published var selectedSegment: SegmentType = .volume + private var fetchTask: Task? + enum SegmentType: String, CaseIterable, Identifiable { case volume = "거래대금 Top5" case rate = "상승률 Top5" @@ -28,45 +30,63 @@ final class TopCoinListViewModel: ObservableObject { } func fetchData() async { - isLoading = true - defer { isLoading = false } + if fetchTask != nil { return } - do { - let coins = try await api.fetchMarkets() - self.coins = coins - - let tickers = try await api.fetchTicker(by: "KRW") - self.tickers = tickers - - let topVolumeIDs = tickers - .sorted { $0.volume > $1.volume } - .prefix(5) - .map { $0.id } - - let topRateIDs = tickers - .sorted { $0.signedRate > $1.signedRate } - .prefix(5) - .map { $0.id } - - let targetIDs = Array(Set(topVolumeIDs + topRateIDs)) + fetchTask = Task { + isLoading = true + defer { + isLoading = false + fetchTask = nil + } - await withTaskGroup(of: Void.self) { group in - for id in targetIDs { - group.addTask { - do { - let candleData = try await self.api.fetchCandles(id: id, count: 10) - await MainActor.run { - self.candles[id] = candleData.map { $0.tradePrice }.reversed() + do { + guard !Task.isCancelled else { return } + let coins = try await api.fetchMarkets() + self.coins = coins + + let tickers = try await api.fetchTicker(by: "KRW") + self.tickers = tickers + + let topVolumeIDs = tickers + .sorted { $0.volume > $1.volume } + .prefix(5) + .map { $0.id } + + let topRateIDs = tickers + .sorted { $0.signedRate > $1.signedRate } + .prefix(5) + .map { $0.id } + + let targetIDs = Array(Set(topVolumeIDs + topRateIDs)) + + guard !Task.isCancelled else { return } + + await withTaskGroup(of: Void.self) { group in + for id in targetIDs { + group.addTask { + do { + let candleData = try await self.api.fetchCandles(id: id, count: 10) + await MainActor.run { + self.candles[id] = candleData.map { $0.tradePrice }.reversed() + } + } catch { + print("Candle fetch failed for \(id):", error) } - } catch { - print("Candle fetch failed for \(id):", error) } } } + } catch { + print("Fetch Error:", error) } - } catch { - print("Fetch Error:", error) } + + await fetchTask?.value + } + + func cancelFetch() { + fetchTask?.cancel() + fetchTask = nil + isLoading = false } var topCoins: [TickerValue] {