HomeView/主页 的实现

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. 效果图:

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/130057.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

2023中考满分多少 中考总分数展示

中考总分根据地区而不同&#xff0c;以下是各地区总分数展示&#xff1a; 大部分地区的中考总分为750分&#xff0c;包括语文150分、数学150分、英语150分&#xff08;其中听力测试30分&#xff09;、思想品德与历史合卷共150分&#xff0c;物理与化学合卷共150分。 安徽中考…

Qt打开ui文件经常报错

报错如下&#xff1a; 解决方法&#xff1a; 最后设置成默认值 即可

强化学习------Qlearning算法

简介 Q learning 算法是一种value-based的强化学习算法&#xff0c;Q是quality的缩写&#xff0c;Q函数 Q(state&#xff0c;action)表示在状态state下执行动作action的quality&#xff0c; 也就是能获得的Q value是多少。算法的目标是最大化Q值&#xff0c;通过在状态state下…

基于三平面映射的地形纹理化【Triplanar Mapping】

你可能遇到过这样的地形&#xff1a;悬崖陡峭的一侧的纹理拉伸得如此之大&#xff0c;以至于看起来不切实际。 也许你有一个程序化生成的世界&#xff0c;你无法对其进行 UV 展开和纹理处理。 推荐&#xff1a;用 NSDT编辑器 快速搭建可编程3D场景 三平面映射&#xff08;Trip…

【可视化大屏】用该软件,无代码,更易用

奥威BI系统是一种自助式BI数据可视化工具&#xff0c;它可以在无代码的情况下&#xff0c;通过简单的拖拉拽实现数据可视化&#xff0c;并支持多种数据源接入&#xff0c;包括各类数据库、Excel、API接口等&#xff0c;只需简单的输入数据源连接地址即可&#xff0c;操作非常方…

[UE虚幻引擎] DTCopyFile 插件说明 – 使用蓝图拷贝复制文件 (Windows)

本插件可以在虚幻引擎中使用蓝图对系统的其他文件进行拷贝复制操作。 1. 节点说明 Async Copy File ​ 异步复制文件 Param Source File : 要复制的源文件的完整路径。Param Target File : 要复制的目标文件的完整路径。Param Force Copy : 如果为true&#xff0c;则如果目标…

大模型部署手记(13)LLaMa2+Chinese-LLaMA-Plus-2-7B+Windows+LangChain+摘要问答

1.简介&#xff1a; 组织机构&#xff1a;Meta&#xff08;Facebook&#xff09; 代码仓&#xff1a;GitHub - facebookresearch/llama: Inference code for LLaMA models 模型&#xff1a;chinese-alpaca-2-7b-hf、text2vec-large-chinese 下载&#xff1a;使用百度网盘和…

手机没电用日语怎么说?你会吗?柯桥常用日语学习

手机没电在日语里可以表达为: 1. スマホの電池が切れた。 直接使用“電池が切れる”来表示电池没有电了。 2. スマホのバッテリーが空に15857575376なった。 “バッテリーが空になる”也是表示电量耗尽的常用表达。 3. 充電が必要だ。 “充電が必要”意思是需要充电。 4…

linux 安装下载conda并创建虚拟环境

目录 1. 下载安装2. 创建虚拟环境1. 下载安装 在window操作系统中下载anconda包,并通过scp传输到ubuntu操作系统 具体anconda包在如下界面: anconda包 目录 博主选择了最新的包:Anaconda3-2023.09-0-Linux-x86_64.sh 通过scp传输到ubuntu操作系统中: 并在ubuntu操作系…

微信小程序--》从模块小程序项目案例23.10.09

配置导航栏 导航栏是小程序的门户&#xff0c;用户进来第一眼看到的便是导航栏&#xff0c;其起着对当前小程序主题的概括。而我们 新建的小程序 时&#xff0c;第一步变开始配置导航栏。如下&#xff1a; 配置tabBar 因为配置tabBar需要借助字体图标&#xff0c;我这里平常喜…

读懂MCU产品选型表

读懂MCU产品选型表 产品状态 MP&#xff1a;Mass Production&#xff08;大规模生产&#xff09; - 这表示产品已经进入了大规模生产阶段&#xff0c;可以大量生产并提供给市场。UD&#xff1a;Under Development&#xff08;开发中&#xff09; - 这表示产品目前正在开发阶段…

京东数据分析平台:2023年8月京东奶粉行业品牌销售排行榜

鲸参谋监测的京东平台8月份奶粉市场销售数据已出炉&#xff01; 鲸参谋数据显示&#xff0c;8月份京东平台上奶粉的销售量将近700万件&#xff0c;环比增长约15%&#xff0c;同比则下滑约19%&#xff1b;销售额将近23亿元&#xff0c;环比增长约4%&#xff0c;同比则下滑约3%。…