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.rhkb.cn/news/154837.html

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

相关文章

手动抄表和自动抄表优缺点对比

随着科技的发展&#xff0c;自动抄表技术已经越来越成熟&#xff0c;被广泛应用于各个领域。然而&#xff0c;手动抄表在一些特定场景下仍然具有一定的优势。本文将从手动抄表和自动抄表的优缺点入手&#xff0c;对比分析它们的应用场景和使用价值。 1.成本低&#xff1a;手动抄…

Android Studio: unrecognized Attribute name MODULE

错误完整代码&#xff1a; &#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xfffd; (1.8.0_291) &#xfffd;г&#xfffd;&#xfffd;&#xfffd;&#xfffd;쳣&#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xfffd;&#xff…

web安全漏洞

1.什么是Web漏洞   WEB漏洞通常是指网站程序上的漏洞&#xff0c;可能是由于代码编写者在编写代码时考虑不周全等原因而造成的漏洞。如果网站存在WEB漏洞并被黑客攻击者利用&#xff0c;攻击者可以轻易控制整个网站&#xff0c;并可进一步提前获取网站服务器权限&#xff0c;…

Linux Centos7 下使用yum安装的nginx平滑升级

1. 查看当前nginx版本 1nginx -v2. 查看centos版本 1cat /etc/redhat-release3. 创建一个新的文件nginx.repo&#xff0c;其中第三行的7是因为我的centos版本是7点多的&#xff0c;你看自己是多少就改多少 1vim /etc/yum.repos.d/nginx.repo23[nginx]4namenginx repo 5baseu…

[ACTF2020 新生赛]Exec1

拿到题目&#xff0c;不知道是sql注入还是命令执行漏洞 先ping一下主机 有回显&#xff0c;说明是命令执行漏洞 我们尝试去查看目录 127.0.0.1|ls&#xff0c;发现有回显&#xff0c;目录下面有个index.php的文件 我们之间访问index.php 输入127.0.0.1;cat index.php 发现又…

广州华锐互动:VR互动教学平台如何赋能职业院校?

随着科技的发展&#xff0c;我们的教育方式也在不断进步。其中&#xff0c;虚拟现实&#xff08;VR&#xff09;技术的出现为我们提供了一种全新的教学方式。特别是在职业学校中&#xff0c;VR互动教学平台已经成为一种重要的教学工具。 VR互动教学平台是一种利用虚拟现实技术创…

go mod 使用三方包、go get命令

一、环境变量设置 go env -w GO111MODULEon go env -w GOPROXYhttps://goproxy.cn,https://goproxy.io,direct 二、goland开启 go mod 三、go mod 使用 在go.mod文件中声明三方包地址&版本号即可&#xff0c;如下&#xff1a; 开发工具goland会自动解析go.mod文件&#x…

Linux 查看CPU架构及内核版本

涉及arch命令和/proc/version文件 1 查看CPU架构 有些软件的安装需要和CPU架构相匹配&#xff0c;如JDK等等&#xff0c;所以需要确定主机的CPU架构类型。可使用命令arch查看Linux系统的CPU架构&#xff0c;如下&#xff1a; arch 12 查看内核版本 文件/proc/version中包含系…

C/C++之自定义类型(结构体,位段,联合体,枚举)详解

个人主页&#xff1a;点我进入主页 专栏分类&#xff1a;C语言初阶 C语言程序设计————KTV C语言小游戏 C语言进阶 C语言刷题 欢迎大家点赞&#xff0c;评论&#xff0c;收藏。 一起努力&#xff0c;一起奔赴大厂。 目录 个人主页&#xff1a;点我进入主页 …

React笔记:useState

1 介绍 useState 是 React 中一个非常重要的钩子&#xff08;Hook&#xff09;&#xff0c;允许在函数组件中添加状态。 2 基本用法 useState 是一个函数&#xff0c;它接收一个参数&#xff08;初始状态值&#xff09;并返回一个数组。 返回的这个数组包含两个元素&#xf…

JAXB 使用记录 bean转xml xml转bean 数组 继承

JAXB 使用记录 部分内容引自 https://blog.csdn.net/gengzhy/article/details/127564536 基础介绍 JAXBContext类&#xff1a;是应用的入口&#xff0c;用于管理XML/Java绑定信息 Marshaller接口&#xff1a;将Java对象序列化为XML数据 Unmarshaller接口&#xff1a;将XML数…

滚珠螺母在工业机器人中的应用优势

工业机器人是广泛用于工业领域的多关节机械手或多自由度的机器装置&#xff0c;具有一定的自动性&#xff0c;可依靠自身的动力能源和控制能力实现各种工业加工制造功能。滚珠螺母作为工业机器人中的重要传动配件&#xff0c;在工业机器人的应用中有哪些优势呢&#xff1f; 1、…

STM32 CubeMX PWM三种模式(HAL库)

STM32 CubeMX PWM两种模式&#xff08;HAL库&#xff09; STM32 CubeMX STM32 CubeMX PWM两种模式&#xff08;HAL库&#xff09;一、互补对称输出STM32 CubeMX设置代码部分 二、带死区互补模式STM32 CubeMX设置代码 三、普通模式STM32 CubeMX设置代码部分 总结 一、互补对称输…

API接口安全运营研究

根据当前API技术发展的趋势&#xff0c;从实际应用中发生的安全事件出发&#xff0c;分析并讨论相关API安全运营问题。从风险角度阐述了API接口安全存在的问题&#xff0c;探讨了API检测技术在安全运营中起到的作用&#xff0c;同时针对API安全运营实践&#xff0c;提出了几个方…

[ICCV-23] DeformToon3D: Deformable Neural Radiance Fields for 3D Toonification

pdf | code 将3D人脸风格化问题拆分为几何风格化与纹理风格化。提出StyleField&#xff0c;学习以风格/ID为控制信号的几何形变残差&#xff0c;实现几何风格化。通过对超分网络引入AdaIN&#xff0c;实现纹理风格化。由于没有修改3D GAN空间&#xff0c;因此可以便捷实现Edit…

代码随想录算法训练营第23期day17| 110.平衡二叉树、257. 二叉树的所有路径、404.左叶子之和

目录 一、&#xff08;leetcode 110&#xff09;平衡二叉树 二、&#xff08;leetcode 257&#xff09;二叉树的所有路径 三、&#xff08;leetcode 404&#xff09;左叶子之和 一、&#xff08;leetcode 110&#xff09;平衡二叉树 力扣题目链接 状态&#xff1a;已AC 求深…

如何在 Spring Boot 中使用 WebSocket

在Spring Boot中使用WebSocket构建实时应用 WebSocket是一种用于实现双向通信的网络协议&#xff0c;它非常适合构建实时应用程序&#xff0c;如在线聊天、实时通知和多人协作工具。Spring Boot提供了对WebSocket的支持&#xff0c;使得在应用程序中集成WebSocket变得非常容易…

QTableWidget 表格部件

QTableWidget是QT中的表格组件类。一般用来展示多行多列的数据&#xff0c;是QT中使用较多的控件之一。1、QTableWidgetItem对象 QTableWidget中的每一个单元格都是一个QTableWidgetItem对象&#xff0c;因此先介绍下QTableWidgetItem的常用方法。 1.1、设置文本内容 void QT…

可拓展的低代码全栈框架

尽管现在越来越多的人开始对低代码开发感兴趣&#xff0c;但已有低代码方案的局限性仍然让大家有所保留。其中最常见的担忧莫过于低代码缺乏灵活性以及容易被厂商锁定。 显然这样的担忧是合理的&#xff0c;因为大家都不希望在实现特定功能的时候才发现低代码平台无法支持&…

ref与DOM-findDomNode-unmountComponentAtNode知识点及应用例子

​​​​​​http​​​http://t.csdnimg.cn/og3BI 知识点讲解↑ 需求: (下载/导出 用post请求时:) 实例: react部分代码 1、点击下载按钮&#xff0c;需要传给后端数据&#xff0c;到数据扁平&#xff0c;不是那么复杂&#xff0c;只需url地址即可完成下载&#xff0c;后端…