1. 创建数据模型
1.1 创建货币模型 CoinModel.swift
import Foundation// GoinGecko API info
/*URL:https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=1&sparkline=true&price_change_percentage=24h&locale=en&precision=2JSON Response{"id": "bitcoin","symbol": "btc","name": "Bitcoin","image": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579","current_price": 29594.97,"market_cap": 575471925043,"market_cap_rank": 1,"fully_diluted_valuation": 621468559135,"total_volume": 17867569837,"high_24h": 29975,"low_24h": 28773,"price_change_24h": 671.94,"price_change_percentage_24h": 2.32321,"market_cap_change_24h": 13013242516,"market_cap_change_percentage_24h": 2.31364,"circulating_supply": 19445731,"total_supply": 21000000,"max_supply": 21000000,"ath": 69045,"ath_change_percentage": -57.13833,"ath_date": "2021-11-10T14:24:11.849Z","atl": 67.81,"atl_change_percentage": 43542.79212,"atl_date": "2013-07-06T00:00:00.000Z","roi": null,"last_updated": "2023-08-02T07:45:52.912Z","sparkline_in_7d": {"price": [29271.02433564558,29245.370873051394]},"price_change_percentage_24h_in_currency": 2.3232080710152045}*//// 硬币模型
struct CoinModel: Identifiable, Codable{let id, symbol, name: Stringlet image: Stringlet currentPrice: Doublelet marketCap, marketCapRank, fullyDilutedValuation, totalVolume: Double?let high24H, low24H: Double?let priceChange24H, priceChangePercentage24H: Double?let marketCapChange24H: Double?let marketCapChangePercentage24H: Double?let circulatingSupply, totalSupply, maxSupply, ath: Double?let athChangePercentage: Double?let athDate: String?let atl, atlChangePercentage: Double?let atlDate: String?let lastUpdated: String?let sparklineIn7D: SparklineIn7D?let priceChangePercentage24HInCurrency: Double?let currentHoldings: Double?enum CodingKeys: String, CodingKey{case id, symbol, name, imagecase currentPrice = "current_price"case marketCap = "market_cap"case marketCapRank = "market_cap_rank"case fullyDilutedValuation = "fully_diluted_valuation"case totalVolume = "total_volume"case high24H = "high_24h"case low24H = "low_24h"case priceChange24H = "price_change_24h"case priceChangePercentage24H = "price_change_percentage_24h"case marketCapChange24H = "market_cap_change_24h"case marketCapChangePercentage24H = "market_cap_change_percentage_24h"case circulatingSupply = "circulating_supply"case totalSupply = "total_supply"case maxSupply = "max_supply"case athcase athChangePercentage = "ath_change_percentage"case athDate = "ath_date"case atlcase atlChangePercentage = "atl_change_percentage"case atlDate = "atl_date"case lastUpdated = "last_updated"case sparklineIn7D = "sparkline_in_7d"case priceChangePercentage24HInCurrency = "price_change_percentage_24h_in_currency"case currentHoldings}// 更新 currentHoldingsfunc updateHoldings(amount: Double) -> CoinModel{return CoinModel(id: id, symbol: symbol, name: name, image: image, currentPrice: currentPrice, marketCap: marketCap, marketCapRank: marketCapRank, fullyDilutedValuation: fullyDilutedValuation, totalVolume: totalVolume, high24H: high24H, low24H: low24H, priceChange24H: priceChange24H, priceChangePercentage24H: priceChangePercentage24H, marketCapChange24H: marketCapChange24H, marketCapChangePercentage24H: marketCapChangePercentage24H, circulatingSupply: circulatingSupply, totalSupply: totalSupply, maxSupply: maxSupply, ath: ath, athChangePercentage: athChangePercentage, athDate: athDate, atl: atl, atlChangePercentage: atlChangePercentage, atlDate: atlDate, lastUpdated: lastUpdated, sparklineIn7D: sparklineIn7D, priceChangePercentage24HInCurrency: priceChangePercentage24HInCurrency, currentHoldings: amount)}// 当前 currentHoldings: 当前持有量 currentPrice: 当前价格var currentHoldingsValue: Double{return (currentHoldings ?? 0) * currentPrice}// 排名var rank: Int{return Int(marketCapRank ?? 0)}}// MARK: - SparklineIn7D
struct SparklineIn7D: Codable{let price: [Double]?
}
1.2 创建统计数据模型 StatisticModel.swift
import Foundation/// 统计数据模型
struct StatisticModel: Identifiable{let id = UUID().uuidStringlet title: Stringlet value: Stringlet percentageChange: Double?init(title: String, value: String, percentageChange: Double? = nil){self.title = titleself.value = valueself.percentageChange = percentageChange}
}
1.3 创建市场数据模型 MarketDataModel.swift
import Foundation// JSON data:
/*URL: https://api.coingecko.com/api/v3/globalJSON Response:{"data": {"active_cryptocurrencies": 10034,"upcoming_icos": 0,"ongoing_icos": 49,"ended_icos": 3376,"markets": 798,"total_market_cap": {"btc": 41415982.085551225,"eth": 660249629.9804014,"ltc": 14655556681.638193,"bch": 5134174420.757854,"bnb": 4974656759.412051,"eos": 1687970651664.1853,"xrp": 1955098545449.6555,"xlm": 8653816219993.665,"link": 164544407719.89197,"dot": 243138384158.18213,"yfi": 188969825.57739097,"usd": 1208744112847.1863,"aed": 4439723170208.301,"ars": 342300135587211.5,"aud": 1852168274068.648,"bdt": 131985176291313.28,"bhd": 455706200496.2936,"bmd": 1208744112847.1863,"brl": 5923450525007.624,"cad": 1621798568577.5525,"chf": 1055975779400.883,"clp": 1038432067347017.2,"cny": 8719154783611.906,"czk": 26637819261281.18,"dkk": 8191626216674.328,"eur": 1099398702910.807,"gbp": 947401548208.496,"hkd": 9438393793079.348,"huf": 426215232621189.9,"idr": 18399550169412116,"ils": 4468853903327.898,"inr": 100074962676574.22,"jpy": 172903189967437.97,"krw": 1592952743697798.8,"kwd": 371735955720.91144,"lkr": 390986477316809.3,"mmk": 2534052004053905.5,"mxn": 20694025572854.312,"myr": 5532421804501.558,"ngn": 907911878041781.4,"nok": 12320972908562.197,"nzd": 1993476504581.048,"php": 68066798482650.87,"pkr": 342404126260727.94,"pln": 4869997394570.292,"rub": 115933647966061.98,"sar": 4534644636646.075,"sek": 12833723369976.055,"sgd": 1625841817635.0283,"thb": 42306043949651.69,"try": 32662320794122.848,"twd": 38455675399008.88,"uah": 44568641287237.47,"vef": 121031548019.38873,"vnd": 28690182404226572,"zar": 22711359059990.625,"xdr": 902640544965.6523,"xag": 52235006540.929985,"xau": 625126192.8411788,"bits": 41415982085551.23,"sats": 4141598208555122.5},"total_volume": {"btc": 1370301.588278819,"eth": 21845217.01679708,"ltc": 484898138.0297936,"bch": 169870832.6831974,"bnb": 164592983.56086707,"eos": 55848702565.24502,"xrp": 64686976069.70232,"xlm": 286322755462.7357,"link": 5444165558.484416,"dot": 8044549403.54382,"yfi": 6252312.249666742,"usd": 39992869763.07196,"aed": 146894010604.11282,"ars": 11325444812447.17,"aud": 61281394280.91332,"bdt": 4366901075233.5366,"bhd": 15077631843.636286,"bmd": 39992869763.07196,"brl": 195985058273.93372,"cad": 53659313204.24844,"chf": 34938330925.19639,"clp": 34357874413455.105,"cny": 288484566748.94366,"czk": 881346866690.6755,"dkk": 271030598576.85486,"eur": 36375034778.56504,"gbp": 31346011391.598164,"hkd": 312281524044.0637,"huf": 14101884847328.004,"idr": 608773027974562.1,"ils": 147857838765.40222,"inr": 3311110189766.445,"jpy": 5720726731565.593,"krw": 52704911602318.8,"kwd": 12299367174.065407,"lkr": 12936295697541.31,"mmk": 83842403610359.19,"mxn": 684688728418.6284,"myr": 183047364905.5799,"ngn": 30039444336438.703,"nok": 407655400054.68567,"nzd": 65956760720.56524,"php": 2252078482098.112,"pkr": 11328885479018.625,"pln": 161130192467.93414,"rub": 3835815401278.992,"sar": 150034610673.73703,"sek": 424620415401.04956,"sgd": 53793089353.60598,"thb": 1399750441707.5242,"try": 1080675328876.8026,"twd": 1272355994571.0083,"uah": 1474611414916.4841,"vef": 4004486049.3763947,"vnd": 949251968366005,"zar": 751434828409.7075,"xdr": 29865035431.401863,"xag": 1728263071.944928,"xau": 20683112.455367908,"bits": 1370301588278.819,"sats": 137030158827881.9},"market_cap_percentage": {"btc": 46.96554813023725,"eth": 18.20564615641025,"usdt": 6.9030113487818845,"bnb": 3.0917977469405105,"xrp": 2.6976159248858225,"usdc": 2.161451122645245,"steth": 1.2093198987489995,"doge": 0.8556120003835122,"ada": 0.8462977860840838,"sol": 0.7808186900563315},"market_cap_change_percentage_24h_usd": 0.3274584437097279,"updated_at": 1691478601}}*/// MARK: - Welcome
struct GlobalData: Codable {let data: MarketDataModel?
}// MARK: - 市场数据模型
struct MarketDataModel: Codable {let totalMarketCap, totalVolume, marketCapPercentage: [String: Double]let marketCapChangePercentage24HUsd: Doubleenum CodingKeys: String, CodingKey{// 总市值case totalMarketCap = "total_market_cap"case totalVolume = "total_volume"case marketCapPercentage = "market_cap_percentage"case marketCapChangePercentage24HUsd = "market_cap_change_percentage_24h_usd"}// 总市值var marketCap: String{// 取指定 key 的值 : usdif let item = totalMarketCap.first(where: {$0.key == "usd"}) {return "$" + item.value.formattedWithAbbreviations()}return ""}// 24 小时交易量var volume: String {if let item = totalVolume.first(where: {$0.key == "usd"}){return "$" + item.value.formattedWithAbbreviations()}return ""}// 比特币占有总市值var btcDominance: String {if let item = marketCapPercentage.first(where: {$0.key == "btc"}){return item.value.asPercentString()}return ""}
}
1.4 创建核心数据库文件 PortfolioContainer.xcdatamodeld,添加参数如图:
2. 创建工具管理类
2.1 创建网络请求管理器 NetworkingManager.swift
import Foundation
import Combine/// 网络请求管理器
class NetworkingManager{/// 错误状态enum NetworkingError: LocalizedError{case badURLResponse(url: URL)case unknownvar errorDescription: String?{switch self {case .badURLResponse(url: let url): return "[🔥] Bad response from URL: \(url)"case .unknown: return "[⚠️] Unknown error occured"}}}/// 下载数据通用方法static func downLoad(url: URL) -> AnyPublisher<Data, any Error>{return URLSession.shared.dataTaskPublisher(for: url)// 默认执行的操作,确保在后台执行线程上//.subscribe(on: DispatchQueue.global(qos: .default)).tryMap({ try handleURLResponse(output: $0, url: url) })//.receive(on: DispatchQueue.main)// 重试次数.retry(3).eraseToAnyPublisher()}/// 返回状态/数据通用方法 throws: 抛出异常static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL)throws -> Data{guard let response = output.response as? HTTPURLResponse,response.statusCode >= 200 && response.statusCode < 300 else {// URLError(.badServerResponse)throw NetworkingError.badURLResponse(url: url)}return output.data}/// 返回完成/失败通用方法static func handleCompletion(completion: Subscribers.Completion<Error>){switch completion{case .finished:breakcase .failure(let error):print(error.localizedDescription)break}}
}
2.2 创建本地文件管理器 LocalFileManager.swift
import Foundation
import SwiftUI/// 本地文件管理器
class LocalFileManager{// 单例模式static let instance = LocalFileManager()// 保证应用程序中只有一个实例并且只能在内部实例化private init() {}// 保存图片func saveImage(image: UIImage, imageName: String, folderName: String) {// 创建文件夹路径createFolderIfNeeded(folderName: folderName)// 获取图片的路径guardlet data = image.pngData(),let url = getURLForImage(imageName: imageName, folderName: folderName)else { return }// 保存文件到指定的文件夹do{try data.write(to: url)}catch let error{print("Error saving image. Image name \(imageName).| \(error.localizedDescription)")}}// 获取图片func getImage(imageName: String, folderName: String) -> UIImage?{guardlet url = getURLForImage(imageName: imageName, folderName: folderName),FileManager.default.fileExists(atPath: url.path)else {return nil}return UIImage(contentsOfFile: url.path)}/// 创建文件夹路径private func createFolderIfNeeded(folderName: String){guard let url = getURLForFolder(folderName: folderName) else { return }if !FileManager.default.fileExists(atPath: url.path){do {try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)} catch let error {print("Error creating directory. Folder name \(folderName).| \(error.localizedDescription)")}}}/// 获取文件夹路径private func getURLForFolder(folderName: String) -> URL? {guard let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil}return url.appendingPathComponent(folderName)}/// 获取图片的路径private func getURLForImage(imageName: String, folderName: String) -> URL?{guard let folderURL = getURLForFolder(folderName: folderName) else { return nil }return folderURL.appendingPathComponent(imageName + ".png")}
}
2.3 创建触觉管理器 HapticManager.swift
import Foundation
import SwiftUI/// 触觉管理器
class HapticManager{/// 通知反馈生成器器static private let generator = UINotificationFeedbackGenerator()/// 通知: 反馈类型static func notification(type: UINotificationFeedbackGenerator.FeedbackType){generator.notificationOccurred(type)}
}
3. 创建扩展类
3.1 创建颜色扩展类 Color.swift
import Foundation
import SwiftUI/// 扩展类 颜色
extension Color{static let theme = ColorTheme()static let launch = LaunchTheme()
}/// 颜色样式
struct ColorTheme{let accent = Color("AccentColor")let background = Color("BackgroundColor")let green = Color("GreenColor")let red = Color("RedColor")let secondaryText = Color("SecondaryTextColor")
}/// 颜色样式2
struct ColorTheme2{let accent = Color(#colorLiteral(red: 0, green: 0.9914394021, blue: 1, alpha: 1))let background = Color(#colorLiteral(red: 0.09019608051, green: 0, blue: 0.3019607961, alpha: 1))let green = Color(#colorLiteral(red: 0, green: 0.5603182912, blue: 0, alpha: 1))let red = Color(#colorLiteral(red: 0.5807225108, green: 0.066734083, blue: 0, alpha: 1))let secondaryText = Color(#colorLiteral(red: 0.7540688515, green: 0.7540867925, blue: 0.7540771365, alpha: 1))
}/// 启动样式
struct LaunchTheme {let accent = Color("LaunchAccentColor")let background = Color("LaunchBackgroundColor")
}
3.2 创建提供预览视图扩展类 PreviewProvider.swift
import Foundation
import SwiftUI/// 扩展类 提供预览
extension PreviewProvider{// 开发者预览数据static var dev: DeveloperPreview{return DeveloperPreview.instance}
}// 开发者预览版
class DeveloperPreview{// 单例模式static let instance = DeveloperPreview()private init() {}// 环境变量,呈现的模式:显示或者关闭@Environment(\.presentationMode) var presentationModelet homeViewModel = HomeViewModel()// 统计数据模型let stat1 = StatisticModel(title: "Market Cap", value: "$12.5Bn", percentageChange: 26.32)let stat2 = StatisticModel(title: "Total Volume", value: "$1.23Tr")let stat3 = StatisticModel(title: "Portfolio Value", value: "$50.4k",percentageChange: -12.32)let coin = CoinModel(id: "bitcoin",symbol: "btc",name: "Bitcoin",image: "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579",currentPrice: 29594.97,marketCap: 575471925043,marketCapRank: 1,fullyDilutedValuation: 621468559135,totalVolume: 17867569837,high24H: 29975,low24H: 28773,priceChange24H: 671.94,priceChangePercentage24H: 2.32321,marketCapChange24H: 13013242516,marketCapChangePercentage24H: 2.31364,circulatingSupply: 19445731,totalSupply: 21000000,maxSupply: 21000000,ath: 69045,athChangePercentage: -57.13833,athDate: "2021-11-10T14:24:11.849Z",atl: 67.81,atlChangePercentage: 43542.79212,atlDate: "2013-07-06T00:00:00.000Z",lastUpdated: "2023-08-02T07:45:52.912Z",sparklineIn7D:SparklineIn7D(price:[29271.02433564558,29245.370873051394,29205.501195094886,29210.97710800848,29183.90996906209,29191.187134377586,29167.309535190096,29223.071887272858,29307.753433422175,29267.687825355235,29313.499192934243,29296.218518715148,29276.651666477588,29343.71801186576,29354.73988657794,29614.69857297837,29473.762709346545,29460.63779255003,29363.672907978616,29325.29799021886,29370.611267446548,29390.15178296929,29428.222505493162,29475.12359313808,29471.20179209623,29396.682959470276,29416.063748693945,29442.757895685798,29550.523558342804,29489.241437118748,29513.005452237085,29481.87017389305,29440.157241806293,29372.682404809886,29327.962010819112,29304.689279369806,29227.558442049805,29178.745455204324,29155.348160823945,29146.414472358578,29190.04784447575,29200.962573823388,29201.236356821602,29271.258206136354,29276.093243553125,29193.96481135078,29225.130187030347,29259.34141509108,29172.589866912043,29177.057442352412,29144.25689537892,29158.76207558714,29202.314532690547,29212.0966881263,29222.654794248145,29302.58488156929,29286.271181422144,29437.329605975596,29387.54866090718,29374.800526401574,29237.366870488135,29306.414045617796,29313.493330593126,29329.5049157853,29317.998848911364,29300.313958408336,29314.09738709836,29331.597426309774,29372.858006614388,29371.93585447968,29365.560710924212,29386.997851302443,29357.263814441514,29344.33621803127,29307.866330609653,29292.411501323997,29279.062208908184,29290.907121380646,29275.952127727414,29296.397048693474,29300.218227669986,29291.762204217895,29291.877166187365,29301.25798859754,29323.60843299231,29305.311033785278,29335.43442901468,29355.10941623317,29350.104456680947,29355.533727400776,29356.74774591667,29337.06524643115,29327.210034664997,29313.84510272745,29316.494745597563,29323.673091844805,29314.269726879855,29276.735658617326,29291.429686285876,29294.892488066977,29281.92132540751,29254.767133836835,29280.924410272044,29317.606859109263,29277.34170421034,29333.335435295256,29377.387821327997,29372.791590384797,29380.712873208802,29357.07852007383,29173.883400452203,29182.94706943146,29210.311445584994,29158.20830261118,29277.755810272716,29454.950860223915,29446.040153631897,29480.745288051072,29419.437853166743,29398.450179898642,29381.999704403723,29401.478326800752,29379.291090327082,29385.90384828296,29370.640322724914,29371.859549109304,29389.802582833345,29449.090796832406,29351.411076211785,29301.70086480563,29250.006595240662,29244.84298676968,29217.38857006191,29197.54498742039,29220.005552322902,29217.05529059147,29239.485487664628,29208.638675444134,29225.78903990318,29283.257482890982,29196.40491920269,28933.589441398828,28836.362892634166,28859.850682516564,28902.83342032919,28923.047091180444,28922.768533406037,28950.689444814736,28926.692827318147,28914.78045754031,28876.0727583824,28873.94607766258,28878.68936584147,28811.350317624612,28893.17367623834,28904.107217880563,28932.211442017186,29162.211547116116,29257.225510262706,29220.838459786457,29190.624191620474,29199.152902607395,29694.16407843016,29772.298033304203,29874.280259270647,29824.984567470103,29613.437605238618,29654.778753257848]),priceChangePercentage24HInCurrency: 2.3232080710152045,currentHoldings: 1.5)
}
3.3 创建双精度扩展类 Double.swift
import Foundation/// 扩展类 双精度
extension Double{/// 双精度数值转换为 小数点为 2位的货币值/// ```/// Convert 1234.56 to $1,234.56/// ```private var currencyFormatter2: NumberFormatter{let formatter = NumberFormatter()// 分组分隔符formatter.usesGroupingSeparator = true// 数字格式 等于 货币formatter.numberStyle = .currency// 发生时间 为当前 default//formatter.locale = .current // <- default value// 当前货币代码 设置为美元 default//formatter.currencyCode = "usd" // <- change currency// 当前货币符号 default//formatter.currencySymbol = "$" // <- change currency symbol// 最小分数位数formatter.minimumFractionDigits = 2// 最大分数位数formatter.maximumFractionDigits = 2return formatter}/// 双精度数值转换为 字符串类型 小数点为 2位的货币值/// ```/// Convert 1234.56 to "$1,234.56"/// ```func asCurrencyWith2Decimals() -> String{let number = NSNumber(value: self)return currencyFormatter2.string(from: number) ?? "$0.00"}/// 双精度数值转换为 小数点为 2位到 6位的货币值/// ```/// Convert 1234.56 to $1,234.56/// Convert 12.3456 to $12.3456/// Convert 0.123456 to $0.123456/// ```private var currencyFormatter6: NumberFormatter{let formatter = NumberFormatter()// 分组分隔符formatter.usesGroupingSeparator = true// 数字格式 等于 货币formatter.numberStyle = .currency// 发生时间 为当前 default//formatter.locale = .current // <- default value// 当前货币代码 设置为美元 default//formatter.currencyCode = "usd" // <- change currency// 当前货币符号 default//formatter.currencySymbol = "$" // <- change currency symbol// 最小分数位数formatter.minimumFractionDigits = 2// 最大分数位数formatter.maximumFractionDigits = 6return formatter}/// 双精度数值转换为 字符串类型 小数点为 2位到 6位的货币值/// ```/// Convert 1234.56 to "$1,234.56"/// Convert 12.3456 to "$12.3456"/// Convert 0.123456 to "$0.123456"/// ```func asCurrencyWith6Decimals() -> String{let number = NSNumber(value: self)return currencyFormatter6.string(from: number) ?? "$0.00"}/// 双精度数值转换为 字符串表现形式/// ```/// Convert 1.23456 to "1.23"/// ```func asNumberString() -> String{return String(format: "%.2f", self)}/// 双精度数值转换为 字符串表现形式带有百分比符号/// ```/// Convert 1.23456 to "1.23%"/// ```func asPercentString() -> String {return asNumberString() + "%"}/// Convert a Double to a String with K, M, Bn, Tr abbreviations./// k : 千, m : 百万, bn : 十亿,Tr : 万亿/// ```/// Convert 12 to 12.00/// Convert 1234 to 1.23K/// Convert 123456 to 123.45K/// Convert 12345678 to 12.34M/// Convert 1234567890 to 1.23Bn/// Convert 123456789012 to 123.45Bn/// Convert 12345678901234 to 12.34Tr/// ```func formattedWithAbbreviations() -> String {let num = abs(Double(self))let sign = (self < 0) ? "-" : ""switch num {case 1_000_000_000_000...:let formatted = num / 1_000_000_000_000let stringFormatted = formatted.asNumberString()return "\(sign)\(stringFormatted)Tr"case 1_000_000_000...:let formatted = num / 1_000_000_000let stringFormatted = formatted.asNumberString()return "\(sign)\(stringFormatted)Bn"case 1_000_000...:let formatted = num / 1_000_000let stringFormatted = formatted.asNumberString()return "\(sign)\(stringFormatted)M"case 1_000...:let formatted = num / 1_000let stringFormatted = formatted.asNumberString()return "\(sign)\(stringFormatted)K"case 0...:return self.asNumberString()default:return "\(sign)\(self)"}}
}
3.4 创建应用扩展类 UIApplication.swift
import Foundation
import SwiftUIextension UIApplication{/// 结束编辑,隐藏键盘func endEditing(){sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)}
}
3.5 创建日期扩展类 Date.swift
import Foundation/// 扩展类 日期
extension Date {// "2021-11-10T14:24:11.849Z"init(coinGeckoString: String) {let formatter = DateFormatter()formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"// 指定日期格式转换let date = formatter.date(from: coinGeckoString) ?? Date()self.init(timeInterval: 0, since: date)}// 输出短格式private var shortFormatter: DateFormatter{let formatter = DateFormatter()formatter.dateStyle = .shortreturn formatter}// 转换为字符串短类型func asShortDateString() -> String{return shortFormatter.string(from: self)}
}
3.6 创建字符串扩展类 String.swift
import Foundation/// 扩展类 字符串
extension String{/// 移除 HTML 内容,查找到 HTML 标记,用 "" 替代var removingHTMLOccurances: String{return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)}
}
4. 创建数据服务类
4.1 创建货币数据服务类 CoinDataService.swift
import Foundation
import Combine/// 货币数据服务
class CoinDataService{// 硬币模型数组 Published: 可以拥有订阅者@Published var allCoins: [CoinModel] = []// 随时取消操作var coinSubscription: AnyCancellable?init() {getCoins()}// 获取全部硬币func getCoins(){guard let url = URL(string: "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=1&sparkline=true&price_change_percentage=24h&locale=en&precision=2")else { return }coinSubscription = NetworkingManager.downLoad(url: url).decode(type: [CoinModel].self, decoder: JSONDecoder()).receive(on: DispatchQueue.main).sink(receiveCompletion: NetworkingManager.handleCompletion,receiveValue: { [weak self] returnCoins in// 解除强引用 (注意)self?.allCoins = returnCoins// 取消订阅者self?.coinSubscription?.cancel()})}
}
4.2 创建货币图片下载缓存服务类 CoinImageService.swift
import Foundation
import SwiftUI
import Combine/// 货币图片下载缓存服务
class CoinImageService{@Published var image: UIImage? = nil// 随时取消操作private var imageSubscription: AnyCancellable?private let coin: CoinModelprivate let fileManager = LocalFileManager.instanceprivate let folderName = "coin_images"private let imageName: Stringinit(coin: CoinModel) {self.coin = coinself.imageName = coin.idgetCoinImage()}// 获取图片: 文件夹获取 / 下载private func getCoinImage(){// 获取图片if let saveImage = fileManager.getImage(imageName: imageName, folderName: folderName){image = saveImage//print("Retrieved image from file manager!")}else{downloadCoinImage()//print("Downloading image now")}}// 下载硬币的图片private func downloadCoinImage(){guard let url = URL(string: coin.image)else { return }imageSubscription = NetworkingManager.downLoad(url: url).tryMap{ data inreturn UIImage(data: data)}.receive(on: DispatchQueue.main).sink(receiveCompletion: NetworkingManager.handleCompletion,receiveValue: { [weak self] returnedImage inguard let self = self, let downloadedImage = returnedImage else { return }// 解除强引用 (注意)self.image = downloadedImage// 取消订阅者self.imageSubscription?.cancel()// 保存图片self.fileManager.saveImage(image: downloadedImage, imageName: self.imageName, folderName: self.folderName);})}
}
4.3 创建市场数据服务类 MarketDataService.swift
import Foundation
import Combine/// 市场数据服务
class MarketDataService{// 市场数据模型数组 Published: 可以拥有订阅者@Published var marketData: MarketDataModel? = nil// 随时取消操作var marketDataSubscription: AnyCancellable?init() {getData()}// 获取全部硬币func getData(){guard let url = URL(string: "https://api.coingecko.com/api/v3/global") else { return }marketDataSubscription = NetworkingManager.downLoad(url: url).decode(type: GlobalData.self, decoder: JSONDecoder()).receive(on: DispatchQueue.main).sink(receiveCompletion: NetworkingManager.handleCompletion,receiveValue: { [weak self] returnGlobalData in// 解除强引用 (注意)self?.marketData = returnGlobalData.data// 取消订阅者self?.marketDataSubscription?.cancel()})}
}
4.4 创建持有交易货币投资组合数据存储服务(核心数据存储) PortfolioDataService.swift
import Foundation
import CoreData/// 持有交易货币投资组合数据存储服务(核心数据存储)
class PortfolioDataService{// 数据容器private let container: NSPersistentContainer// 容器名称private let containerName: String = "PortfolioContainer"// 实体名称private let entityName: String = "PortfolioEntity"// 投资组合实体集合@Published var savedEntities: [PortfolioEntity] = []init() {// 获取容器文件container = NSPersistentContainer(name: containerName)// 加载持久存储container.loadPersistentStores { _, error inif let error = error {print("Error loading core data! \(error)")}self.getPortfolio()}}// MARK: PUBLIC// 公开方法/// 更新 / 删除 / 添加 投资组合数据func updatePortfolio(coin: CoinModel, amount: Double){// 判断货币数据是否在投资组合实体集合中if let entity = savedEntities.first(where: {$0.coinID == coin.id}){// 存在则更新if amount > 0{update(entity: entity, amount: amount)}else{delete(entity: entity)}}else{add(coin: coin, amount: amount)}}// MARK: PRIVATE// 私有方法/// 获取容器里的投资组合实体数据private func getPortfolio(){// 根据实体名称,获取实体类型let request = NSFetchRequest<PortfolioEntity>(entityName: entityName)do {savedEntities = try container.viewContext.fetch(request)} catch let error {print("Error fatching portfolio entities. \(error)")}}/// 添加数据private func add(coin: CoinModel, amount: Double){let entity = PortfolioEntity(context: container.viewContext)entity.coinID = coin.identity.amount = amountapplyChanges()}/// 更新数据private func update(entity: PortfolioEntity, amount: Double){entity.amount = amountapplyChanges()}/// 删除数据private func delete(entity: PortfolioEntity){container.viewContext.delete(entity)applyChanges()}/// 共用保存方法private func save(){do {try container.viewContext.save()} catch let error {print("Error saving to core data. \(error)")}}// 应用并且改变private func applyChanges(){save()getPortfolio()}
}
5. 创建主页 ViewModel HomeViewModel.swift
import Foundation
import Combine/// 主页 ViewModel
class HomeViewModel: ObservableObject{/// 统计数据模型数组@Published var statistics: [StatisticModel] = []/// 硬币模型数组@Published var allCoins: [CoinModel] = []/// 持有交易货币投资组合模型数组@Published var portfolioCoins: [CoinModel] = []/// 是否重新加载数据@Published var isLoading: Bool = false/// 搜索框文本@Published var searchText: String = ""/// 默认排序方式为持有最多的交易货币@Published var sortOption: SortOption = .holdings/// 货币数据服务private let coinDataService = CoinDataService()/// 市场数据请求服务private let marketDataService = MarketDataService()/// 持有交易货币投资组合数据存储服务(核心数据存储)private let portfolioDataService = PortfolioDataService()/// 随时取消集合private var cancellables = Set<AnyCancellable>()/// 排序选项enum SortOption {case rank, rankReversed, holdings, holdingsReversed, price, priceReversed}init(){addSubscribers()}// 添加订阅者func addSubscribers(){// 更新货币消息$searchText// 组合订阅消息.combineLatest(coinDataService.$allCoins, $sortOption)// 运行其余代码之前等待 0.5 秒、文本框输入停下来之后,停顿 0.5 秒后,再执行后面的操作.debounce(for: .seconds(0.5), scheduler: DispatchQueue.main).map(filterAndSortCoins).sink {[weak self] returnedCoins inself?.allCoins = returnedCoins}.store(in: &cancellables)// 更新持有交易货币投资组合数据$allCoins// 组合订阅消息.combineLatest(portfolioDataService.$savedEntities)// 根据投资组合实体中数据,获取持有的货币信息.map(mapAllCoinsToPortfolioCoins).sink {[weak self] returnedCoins inguard let self = self else { return }// 排序self.portfolioCoins = self.sortPortfolioCoinsIfNeeded(coins: returnedCoins)}.store(in: &cancellables)// 更新市场数据,订阅市场数据服务marketDataService.$marketData// 组合订阅持有交易货币投资组合的数据.combineLatest($portfolioCoins)// 转换为统计数据模型数组.map(mapGlobalMarketData).sink {[weak self] returnedStats inself?.statistics = returnedStatsself?.isLoading = false}.store(in: &cancellables)}/// 更新持有交易货币组合投资中的数据func updatePortfolio(coin: CoinModel, amount: Double){portfolioDataService.updatePortfolio(coin: coin, amount: amount)}/// 重新加载货币数据func reloadData(){isLoading = truecoinDataService.getCoins()marketDataService.getData()// 添加触动提醒HapticManager.notification(type: .success)}/// 过滤器和排序方法private func filterAndSortCoins(text: String, coins: [CoinModel], sort: SortOption) -> [CoinModel] {// 过滤var updatedCoins = filterCoins(text: text, coins: coins)// 排序sortCoins(sort: sort, coins: &updatedCoins)return updatedCoins}/// 过滤器方法private func filterCoins(text: String, coins:[CoinModel]) -> [CoinModel]{guard !text.isEmpty else{// 为空返回原数组return coins}// 文本转小写let lowercasedText = text.lowercased()// 过滤器return coins.filter { coin -> Bool in// 过滤条件return coin.name.lowercased().contains(lowercasedText) ||coin.symbol.lowercased().contains(lowercasedText) ||coin.id.lowercased().contains(lowercasedText)}}/// 排序方法 inout: 基于原有的数组上进行改变private func sortCoins(sort: SortOption, coins: inout [CoinModel]) {switch sort {case .rank, .holdings:coins.sort(by: { $0.rank < $1.rank })case .rankReversed, .holdingsReversed:coins.sort(by: { $0.rank > $1.rank })case .price:coins.sort(by: { $0.currentPrice > $1.currentPrice })case .priceReversed:coins.sort(by: { $0.currentPrice < $1.currentPrice })}}/// 排序持有的交易货币private func sortPortfolioCoinsIfNeeded(coins: [CoinModel]) -> [CoinModel]{// 只会按持有金额高到低或者低到高进行switch sortOption {case .holdings:return coins.sorted(by: { $0.currentHoldingsValue > $1.currentHoldingsValue })case .holdingsReversed:return coins.sorted(by: { $0.currentHoldingsValue < $1.currentHoldingsValue })default:return coins}}///在交易货币集合中,根据投资组合实体中数据,获取持有的货币信息private func mapAllCoinsToPortfolioCoins(allCoins: [CoinModel], portfolioEntities: [PortfolioEntity]) -> [CoinModel]{allCoins.compactMap { coin -> CoinModel? inguard let entity = portfolioEntities.first(where: {$0.coinID == coin.id}) else {return nil}return coin.updateHoldings(amount: entity.amount)}}///市场数据模型 转换为 统计数据模型数组private func mapGlobalMarketData(marketDataModel: MarketDataModel?, portfolioCoins: [CoinModel]) -> [StatisticModel]{// 生成统计数据模型数组var stats: [StatisticModel] = []// 检测是否有数据guard let data = marketDataModel else{return stats}// 总市值let marketCap = StatisticModel(title: "Market Cap", value: data.marketCap, percentageChange: data.marketCapChangePercentage24HUsd)// 24 小时交易量let volume = StatisticModel(title: "24h Volume", value: data.volume)// 比特币占有总市值let btcDominance = StatisticModel(title: "BTC Dominance", value: data.btcDominance)// 持有交易货币的金额let portfolioValue =portfolioCoins.map({ $0.currentHoldingsValue })// 集合快速求和.reduce(0, +)// 持有交易货币的增长率// 之前的变化价格 24小时let previousValue =portfolioCoins.map { coin -> Double inlet currentValue = coin.currentHoldingsValuelet percentChange = (coin.priceChangePercentage24H ?? 0) / 100// 假如当前值为: 110,之前24小时上涨了 10%,之前的值为 100// 110 / (1 + 0.1) = 100let previousValue = currentValue / (1 + percentChange)return previousValue}.reduce(0, +)//* 100 百分比 (* 100 : 0.1 -> 10%)let percentageChange = ((portfolioValue - previousValue) / previousValue) * 100// 持有的交易货币金额与增长率let portfolio = StatisticModel(title: "Portfolio Value",value: portfolioValue.asCurrencyWith2Decimals(),percentageChange: percentageChange)// 添加到数组stats.append(contentsOf: [marketCap,volume,btcDominance,portfolio])return stats}
}
6. 视图组件
6.1 货币图片、标志、名称视图组件
1) 创建货币图片 ViewModel CoinImageViewModel.swift
import Foundation
import SwiftUI
import Combine/// 货币图片 ViewModel
class CoinImageViewModel: ObservableObject{@Published var image: UIImage? = nil@Published var isLoading: Bool = true/// 货币模型private let coin: CoinModel/// 货币图片下载缓存服务private let dataService:CoinImageServiceprivate var cancellable = Set<AnyCancellable>()init(coin: CoinModel) {self.coin = coinself.dataService = CoinImageService(coin: coin)self.addSubscribers()self.isLoading = true}/// 添加订阅者private func addSubscribers(){dataService.$image.sink(receiveCompletion: { [weak self]_ inself?.isLoading = false}, receiveValue: { [weak self] returnedImage inself?.image = returnedImage}).store(in: &cancellable)}
}
2) 创建货币图片视图 CoinImageView.swift
import SwiftUI/// 货币图片视图
struct CoinImageView: View {//= CoinImageViewModel(coin: DeveloperPreview.instance.coin)@StateObject private var viewModel: CoinImageViewModelinit(coin: CoinModel) {_viewModel = StateObject(wrappedValue: CoinImageViewModel(coin: coin))}// 内容var body: some View {ZStack {if let image = viewModel.image {Image(uiImage: image).resizable()// 缩放适应该视图的任何大小.scaledToFit()}else if viewModel.isLoading{ProgressView()}else{Image(systemName: "questionmark").foregroundColor(Color.theme.secondaryText)}}}
}struct CoinImageView_Previews: PreviewProvider {static var previews: some View {CoinImageView(coin: dev.coin).padding().previewLayout(.sizeThatFits)}
}
3) 创建货币图片、标志、名称视图 CoinLogoView.swift
import SwiftUI/// 货币的图片与名称
struct CoinLogoView: View {let coin: CoinModelvar body: some View {VStack {CoinImageView(coin: coin).frame(width: 50, height: 50)Text(coin.symbol.uppercased()).font(.headline).foregroundColor(Color.theme.accent).lineLimit(1).minimumScaleFactor(0.5)Text(coin.name).font(.caption).foregroundColor(Color.theme.secondaryText).lineLimit(2).minimumScaleFactor(0.5).multilineTextAlignment(.center)}}
}struct CoinLogoView_Previews: PreviewProvider {static var previews: some View {CoinLogoView(coin: dev.coin).previewLayout(.sizeThatFits)}
}
6.2 圆形按钮视图组件
1) 创建带阴影圆形按钮视图 CircleButtonView.swift
import SwiftUI/// 带阴影圆形按钮视图
struct CircleButtonView: View {let iconName: Stringvar body: some View {Image(systemName: iconName).font(.headline).foregroundColor(Color.theme.accent).frame(width: 50, height: 50).background(Circle().foregroundColor(Color.theme.background)).shadow(color: Color.theme.accent.opacity(0.25), radius: 10, x: 0, y: 0).padding()}
}struct CircleButtonView_Previews: PreviewProvider {static var previews: some View {Group {CircleButtonView(iconName: "info")// 预览区域 点预览布局,适合点的大小.previewLayout(.sizeThatFits)CircleButtonView(iconName: "plus")// 预览区域 点预览布局,适合点的大小 preferredColorScheme.previewLayout(.sizeThatFits).preferredColorScheme(.dark)}}
}
2) 创建圆形按钮动画视图 CircleButtonAnimationView.swift
import SwiftUI/// 圆形按钮动画视图
struct CircleButtonAnimationView: View {// 是否动画@Binding var animate: Boolvar body: some View {Circle().stroke(lineWidth: 5.0).scale(animate ? 1.0 : 0.0).opacity(animate ? 0.0 : 1.0).animation(animate ? Animation.easeOut(duration: 1.0) : .none)}
}struct CircleButtonAnimationView_Previews: PreviewProvider {static var previews: some View {CircleButtonAnimationView(animate: .constant(false)).foregroundColor(.red).frame(width: 100, height: 100)}
}
6.3 创建搜索框视图 SearchBarView.swift
import SwiftUI/// 搜索框视图
struct SearchBarView: View {@Binding var searchText: Stringvar body: some View {HStack {Image(systemName: "magnifyingglass").foregroundColor(searchText.isEmpty ?Color.theme.secondaryText : Color.theme.accent)TextField("Search by name or symbol...", text: $searchText).foregroundColor(Color.theme.accent)// 键盘样式.keyboardType(.namePhonePad)// 禁用自动更正.autocorrectionDisabled(true)//.textContentType(.init(rawValue: "")).overlay(Image(systemName: "xmark.circle.fill").padding() // 加大图片到区域.offset(x: 10).foregroundColor(Color.theme.accent).opacity(searchText.isEmpty ? 0.0 : 1.0).onTapGesture {// 结束编辑 隐藏键盘UIApplication.shared.endEditing()searchText = ""},alignment: .trailing)}.font(.headline).padding().background(RoundedRectangle(cornerRadius: 25)// 填充颜色.fill(Color.theme.background)// 阴影.shadow(color: Color.theme.accent.opacity(0.15),radius: 10, x: 0, y: 0)).padding()}
}struct SearchBarView_Previews: PreviewProvider {static var previews: some View {Group {SearchBarView(searchText: .constant("")).previewLayout(.sizeThatFits).preferredColorScheme(.light)SearchBarView(searchText: .constant("")).previewLayout(.sizeThatFits).preferredColorScheme(.dark)}}
}
6.4 创建统计数据视图 StatisticView.swift
import SwiftUI/// 统计数据视图
struct StatisticView: View {let stat : StatisticModelvar body: some View {VStack(alignment: .leading, spacing: 4) {Text(stat.title).font(.caption).foregroundColor(Color.theme.secondaryText)Text(stat.value).font(.headline).foregroundColor(Color.theme.accent)HStack (spacing: 4){Image(systemName: "triangle.fill").font(.caption2).rotationEffect(Angle(degrees: (stat.percentageChange ?? 0) >= 0 ? 0 : 180))Text(stat.percentageChange?.asPercentString() ?? "").font(.caption).bold()}.foregroundColor((stat.percentageChange ?? 0) >= 0 ? Color.theme.green : Color.theme.red).opacity(stat.percentageChange == nil ? 0.0 : 1.0)}}
}struct StatisticView_Previews: PreviewProvider {static var previews: some View {Group {StatisticView(stat: dev.stat1).previewLayout(.sizeThatFits)//.preferredColorScheme(.dark)StatisticView(stat: dev.stat2).previewLayout(.sizeThatFits)StatisticView(stat: dev.stat3).previewLayout(.sizeThatFits)//.preferredColorScheme(.dark)}}
}
6.5 创建通用关闭按钮视图 XMarkButton.swift
import SwiftUI/// 通用关闭按钮视图
struct XMarkButton: View {// 环境变量: 呈现方式let presentationMode: Binding<PresentationMode>var body: some View {Button(action: {presentationMode.wrappedValue.dismiss()}, label: {HStack {Image(systemName: "xmark").font(.headline)}}).foregroundColor(Color.theme.accent)}
}struct XMarkButton_Previews: PreviewProvider {static var previews: some View {XMarkButton(presentationMode: dev.presentationMode)}
}
7. 主页 View/视图 层
7.1 创建主页货币数据统计视图 HomeStatsView.swift
import SwiftUI/// 主页货币数据统计视图
struct HomeStatsView: View {/// 环境对象,主 ViewModel@EnvironmentObject private var viewModel: HomeViewModel/// 输出货币统计数据或者持有货币统计数据@Binding var showPortfolio: Boolvar body: some View {HStack {ForEach(viewModel.statistics) { stat inStatisticView(stat: stat).frame(width: UIScreen.main.bounds.width / 3)}}.frame(width: UIScreen.main.bounds.width, alignment: showPortfolio ? .trailing : .leading)}
}struct HomeStatsView_Previews: PreviewProvider {static var previews: some View {// .constant(false)HomeStatsView(showPortfolio: .constant(false)).environmentObject(dev.homeViewModel)}
}
7.2 创建货币列表行视图 CoinRowView.swift
import SwiftUI/// 货币列表行视图
struct CoinRowView: View {/// 硬币模型let coin: CoinModel;/// 控股列let showHoldingsColumn: Boolvar body: some View {HStack(spacing: 0) {leftColumnSpacer()if showHoldingsColumn {centerColumn}rightColumn}.font(.subheadline)// 追加热区限制,使 Spacer 也可点击//.contentShape(Rectangle())// 添加背景,使得 Spacer 也可点击.background(Color.theme.background.opacity(0.001))}
}// 扩展类
extension CoinRowView{// 左边的Viewprivate var leftColumn: some View{HStack(spacing: 0) {// 显示排名,图片,名称Text("\(coin.rank)").font(.caption).foregroundColor(Color.theme.secondaryText).frame(minWidth: 30)CoinImageView(coin: coin).frame(width: 30, height: 30)Text(coin.symbol.uppercased()).font(.headline).padding(.leading, 6).foregroundColor(Color.theme.accent)}}// 中间的Viewprivate var centerColumn: some View{// 显示持有的股份VStack(alignment: .trailing) {// 显示持有的金额Text(coin.currentHoldingsValue.asCurrencyWith2Decimals()).bold()// 显示我们的持有量Text((coin.currentHoldings ?? 0).asNumberString())}.foregroundColor(Color.theme.accent)}// 右边的Viewprivate var rightColumn: some View{// 当前价格及上涨或者下跌24小时的百分比VStack(alignment: .trailing) {Text(coin.currentPrice.asCurrencyWith6Decimals()).bold().foregroundColor(Color.theme.accent)Text(coin.priceChangePercentage24H?.asPercentString() ?? "").foregroundColor((coin.priceChangePercentage24H ?? 0 ) >= 0 ? Color.theme.green : Color.theme.red)}.frame(width: UIScreen.main.bounds.width / 3.5, alignment: .trailing)}
}struct CoinRowView_Previews: PreviewProvider {static var previews: some View {Group {CoinRowView(coin: dev.coin, showHoldingsColumn: true).previewLayout(.sizeThatFits)CoinRowView(coin: dev.coin, showHoldingsColumn: true).previewLayout(.sizeThatFits).preferredColorScheme(.dark)}}
}
7.3 创建编辑持有交易货币投资组合视图 PortfolioView.swift
import SwiftUI/// 编辑持有交易货币投资组合视图
struct PortfolioView: View {/// 环境变量,呈现方式:显示或者关闭@Environment(\.presentationMode) var presentationMode/// 环境变量中的主页 ViewModel@EnvironmentObject private var viewModel: HomeViewModel/// 是否选择其中一个模型@State private var selectedCoin: CoinModel? = nil/// 持有的数量@State private var quantityText: String = ""/// 是否点击保存按钮@State private var showCheckmark: Bool = falsevar body: some View {NavigationView {ScrollView {VStack(alignment: .leading, spacing: 0) {// 搜索框SearchBarView(searchText: $viewModel.searchText)// 带图片的水平货币列表coinLogoList//根据当前货币的金额,计算出持有的金额if selectedCoin != nil{portfolioInputSection}}}.background(Color.theme.background.ignoresSafeArea()).navigationTitle("Edit portfolio")// navigationBarItems 已过时,推荐使用 toolbar,动态调整 View// .navigationBarItems(leading: XMarkButton()).toolbar {// 关闭按钮ToolbarItem(placement: .navigationBarLeading) {XMarkButton(presentationMode: presentationMode)}// 确认按钮ToolbarItem(placement: .navigationBarTrailing) {trailingNavBarButton}}// 观察页面上搜索的文字发生变化.onChange(of: viewModel.searchText) { value in// value == ""// 如果搜索框中的文字为空,移除选中列表中的货币if value.isEmpty {removeSelectedCoin()}}}}
}// View 的扩展
extension PortfolioView{/// 带图片的水平货币列表private var coinLogoList: some View {ScrollView(.horizontal, showsIndicators: false) {LazyHStack(spacing: 10) {ForEach(viewModel.searchText.isEmpty ? viewModel.portfolioCoins : viewModel.allCoins) { coin inCoinLogoView(coin: coin).frame(width: 75).padding(4).onTapGesture {withAnimation(.easeIn) {updateSelectedCoin(coin: coin)}}.background(RoundedRectangle(cornerRadius: 10).stroke(selectedCoin?.id == coin.id ?Color.theme.green : Color.clear, lineWidth: 1))}}.frame(height: 120).padding(.leading)}}/// 更新点击的货币信息private func updateSelectedCoin(coin: CoinModel){selectedCoin = coinif let portfolioCoin = viewModel.portfolioCoins.first(where: {$0.id == coin.id}),let amount = portfolioCoin.currentHoldings{quantityText = "\(amount)"}else{quantityText = ""}}/// 获取当前持有货币金额private func getCurrentValue() -> Double {// 获取数量if let quantity = Double(quantityText){return quantity * (selectedCoin?.currentPrice ?? 0)}return 0}/// 根据当前货币的金额,计算出持有的金额private var portfolioInputSection: some View {VStack(spacing: 20) {// 当前货币的价格HStack {Text("Current price of \(selectedCoin?.symbol.uppercased() ?? ""):")Spacer()Text(selectedCoin?.currentPrice.asCurrencyWith6Decimals() ?? "")}Divider()// 持有的货币数量HStack {Text("Amount holding:")Spacer()TextField("Ex: 1.4", text: $quantityText)// 右对齐.multilineTextAlignment(.trailing)// 设置键盘类型,只能为数字.keyboardType(.decimalPad)}Divider()HStack {Text("Current value:")Spacer()Text(getCurrentValue().asCurrencyWith2Decimals())}}.animation(.none).padding().font(.headline)}/// 导航栏右侧的保存按钮private var trailingNavBarButton: some View{HStack(spacing: 10) {Image(systemName: "checkmark").opacity(showCheckmark ? 1.0 : 0.0)//.foregroundColor(Color.theme.accent)Button {saveButtonPressed()} label: {Text("Save".uppercased())}// 选中当前的货币并且持有的货币数量与输入的数量不相等时,显示保存按钮.opacity((selectedCoin != nil && selectedCoin?.currentHoldings != Double(quantityText)) ? 1.0 : 0.0)}.font(.headline)}/// 按下保存按钮private func saveButtonPressed(){// 判断是否有选中按钮guardlet coin = selectedCoin,let amount = Double(quantityText)else { return }// 保存/更新到持有投资组合货币viewModel.updatePortfolio(coin: coin, amount: amount)// 显示检查标记withAnimation(.easeIn) {showCheckmark = trueremoveSelectedCoin()}// 隐藏键盘UIApplication.shared.endEditing()// 隐藏检查标记DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {withAnimation(.easeOut){showCheckmark = false}}}// 移除选中列表中的货币private func removeSelectedCoin(){selectedCoin = nil// 清空搜索框viewModel.searchText = ""}
}struct PortfolioView_Previews: PreviewProvider {static var previews: some View {PortfolioView().environmentObject(dev.homeViewModel)}
}
7.4 创建主页视图 HomeView.swift
import SwiftUI// .constant("") State(wrappedValue:)
// 加密货币
struct HomeView: View {@EnvironmentObject private var viewModel:HomeViewModel/// 是否显示动画@State private var showPortfolio: Bool = false/// 是否显示编辑持有货币 View@State private var showPortfolioView: Bool = false/// 是否显示设置View@State private var showSettingView: Bool = false/// 选中的交易货币@State private var selectedCoin: CoinModel? = nil/// 是否显示交易货币详情页@State private var showDetailView: Bool = falsevar body: some View {ZStack {// 背景布局 background layerColor.theme.background.ignoresSafeArea()// 新的工作表单,持有货币组合 View.sheet(isPresented: $showPortfolioView) {PortfolioView()// 环境变量对象添加 ViewModel.environmentObject(viewModel)}// 内容布局VStack {// 顶部导航栏homeHeader// 统计栏HomeStatsView(showPortfolio: $showPortfolio)// 搜索框SearchBarView(searchText: $viewModel.searchText)// 列表标题栏columnTitles// 货币列表数据coinSectionUsingTransitions//coinSectionUsingOffsetsSpacer(minLength: 0)}// 设置页面.sheet(isPresented: $showSettingView) {SettingsView()}}.background(NavigationLink(destination: DetailLoadingView(coin: $selectedCoin),isActive: $showDetailView,label: { EmptyView() }))}
}struct HomeView_Previews: PreviewProvider {static var previews: some View {NavigationView {HomeView()//.navigationBarHidden(true)}.environmentObject(dev.homeViewModel)}
}// 扩展 HomeView
extension HomeView{// 主页顶部 Viewprivate var homeHeader: some View{HStack {CircleButtonView(iconName: showPortfolio ? "plus" : "info").animation(.none).onTapGesture {if showPortfolio {showPortfolioView.toggle()} else {showSettingView.toggle()}}.background(CircleButtonAnimationView(animate: $showPortfolio))Spacer()Text(showPortfolio ? "Portfolio" : "Live Prices").font(.headline).fontWeight(.heavy).foregroundColor(Color.theme.accent).animation(.none)Spacer()CircleButtonView(iconName: "chevron.right").rotationEffect(Angle(degrees: showPortfolio ? 180 : 0)).onTapGesture {// 添加动画withAnimation(.spring()){showPortfolio.toggle()}}}.padding(.horizontal)}/// 交易货币数据列表private var coinSectionUsingTransitions: some View{ZStack(alignment: .top) {if !showPortfolio{if !viewModel.allCoins.isEmpty {allCoinsList// 将 view 从右侧推到左侧.transition(.move(edge: .leading))}}// 持有的货币列表if showPortfolio{ZStack(alignment: .top) {if viewModel.portfolioCoins.isEmpty && viewModel.searchText.isEmpty{// 当没有持有交易货币时,给出提示语portfolioEmptyText} else{// 持有交易货币投资组合列表if !viewModel.portfolioCoins.isEmpty {portfolioCoinsList}}}.transition(.move(edge: .trailing))}}}/// 交易货币数据列表private var coinSectionUsingOffsets: some View{ZStack(alignment: .top) {if !showPortfolio{allCoinsList// 将 view 从右侧推到左侧.offset(x: showPortfolio ? -UIScreen.main.bounds.width : 0)}// 持有的货币列表if showPortfolio{ZStack(alignment: .top) {if viewModel.portfolioCoins.isEmpty && viewModel.searchText.isEmpty{// 当没有持有交易货币时,给出提示语portfolioEmptyText} else{// 持有交易货币投资组合列表portfolioCoinsList}}.offset(x: showPortfolio ? 0 : UIScreen.main.bounds.width)}}}/// 交易货币列表private var allCoinsList: some View{List {ForEach(viewModel.allCoins) { coin inCoinRowView(coin: coin, showHoldingsColumn: false).listRowInsets(.init(top: 10, leading: 0, bottom: 10, trailing: 10)).onTapGesture {segue(coin: coin)}.listRowBackground(Color.theme.background)}}//.modifier(ListBackgroundModifier())//.background(Color.theme.background.ignoresSafeArea()).listStyle(.plain)}/// 持有交易货币投资组合列表private var portfolioCoinsList: some View{List {ForEach(viewModel.portfolioCoins) { coin inCoinRowView(coin: coin, showHoldingsColumn: true).listRowInsets(.init(top: 10, leading: 0, bottom: 10, trailing: 10)).onTapGesture {segue(coin: coin)}.listRowBackground(Color.theme.background)}}.listStyle(.plain)}/// 当没有持有交易货币时,给出提示语private var portfolioEmptyText: some View{Text("You haven't added any coins to your portfolio yet. Click the + button to get started! 🧐").font(.callout).foregroundColor(Color.theme.accent).fontWeight(.medium).multilineTextAlignment(.center).padding(50)}/// 跳转到交易货币详情页private func segue(coin: CoinModel){selectedCoin = coinshowDetailView.toggle()}/// 列表的标题private var columnTitles: some View{HStack {// 硬币HStack(spacing: 4) {Text("Coin")Image(systemName: "chevron.down").opacity((viewModel.sortOption == .rank || viewModel.sortOption == .rankReversed) ? 1.0 : 0.0).rotationEffect(Angle(degrees: viewModel.sortOption == .rank ? 0 : 180))}.onTapGesture {// 设置排序withAnimation(.default) {viewModel.sortOption = (viewModel.sortOption == .rank ? .rankReversed : .rank)}}Spacer()if showPortfolio{// 持有交易货币的控股HStack(spacing: 4) {Text("Holdings")Image(systemName: "chevron.down").opacity((viewModel.sortOption == .holdings || viewModel.sortOption == .holdingsReversed) ? 1.0 : 0.0).rotationEffect(Angle(degrees: viewModel.sortOption == .holdings ? 0 : 180))}.onTapGesture {// 设置排序withAnimation(.default) {viewModel.sortOption = (viewModel.sortOption == .holdings ? .holdingsReversed : .holdings)}}}HStack(spacing: 4) {// 价格Text("Price").frame(width: UIScreen.main.bounds.width / 3.5, alignment: .trailing)Image(systemName: "chevron.down").opacity((viewModel.sortOption == .price || viewModel.sortOption == .priceReversed) ? 1.0 : 0.0).rotationEffect(Angle(degrees: viewModel.sortOption == .price ? 0 : 180))}.onTapGesture {// 设置排序withAnimation(.default) {viewModel.sortOption = (viewModel.sortOption == .price ? .priceReversed : .price)}}// 刷新Button {withAnimation(.linear(duration: 2.0)) {viewModel.reloadData()}} label: {Image(systemName: "goforward")}// 添加旋转动画.rotationEffect(Angle(degrees: viewModel.isLoading ? 360 : 0), anchor: .center)}.font(.caption).foregroundColor(Color.theme.secondaryText).padding(.horizontal)}
}
8. 效果图: