- 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=2
-
- JSON 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: String
- let image: String
- let currentPrice: Double
- let 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, image
- case 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 ath
- case athChangePercentage = "ath_change_percentage"
- case athDate = "ath_date"
- case atl
- case 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
- }
-
- // 更新 currentHoldings
- func 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]?
- }
- import Foundation
-
- /// 统计数据模型
- struct StatisticModel: Identifiable{
- let id = UUID().uuidString
- let title: String
- let value: String
- let percentageChange: Double?
-
- init(title: String, value: String, percentageChange: Double? = nil){
- self.title = title
- self.value = value
- self.percentageChange = percentageChange
- }
- }
- import Foundation
-
- // JSON data:
- /*
-
- URL: https://api.coingecko.com/api/v3/global
-
- JSON 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: Double
-
- enum 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 的值 : usd
- if 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 ""
- }
- }

- import Foundation
- import Combine
-
- /// 网络请求管理器
- class NetworkingManager{
- /// 错误状态
- enum NetworkingError: LocalizedError{
- case badURLResponse(url: URL)
- case unknown
- var 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:
- break
- case .failure(let error):
- print(error.localizedDescription)
- break
- }
- }
- }
- import Foundation
- import SwiftUI
-
- /// 本地文件管理器
- class LocalFileManager{
- // 单例模式
- static let instance = LocalFileManager()
- // 保证应用程序中只有一个实例并且只能在内部实例化
- private init() {}
-
- // 保存图片
- func saveImage(image: UIImage, imageName: String, folderName: String) {
- // 创建文件夹路径
- createFolderIfNeeded(folderName: folderName)
-
- // 获取图片的路径
- guard
- let 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?{
- guard
- let 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")
- }
- }
- import Foundation
- import SwiftUI
-
- /// 触觉管理器
- class HapticManager{
-
- /// 通知反馈生成器器
- static private let generator = UINotificationFeedbackGenerator()
-
- /// 通知: 反馈类型
- static func notification(type: UINotificationFeedbackGenerator.FeedbackType){
- generator.notificationOccurred(type)
- }
- }
- 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")
- }
- import Foundation
- import SwiftUI
-
- /// 扩展类 提供预览
- extension PreviewProvider{
- // 开发者预览数据
- static var dev: DeveloperPreview{
- return DeveloperPreview.instance
- }
- }
-
- // 开发者预览版
- class DeveloperPreview{
- // 单例模式
- static let instance = DeveloperPreview()
- private init() {}
-
- // 环境变量,呈现的模式:显示或者关闭
- @Environment(\.presentationMode) var presentationMode
-
- let 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
- )
- }
- 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 = 2
- return 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 = 6
- return 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_000
- let stringFormatted = formatted.asNumberString()
- return "\(sign)\(stringFormatted)Tr"
- case 1_000_000_000...:
- let formatted = num / 1_000_000_000
- let stringFormatted = formatted.asNumberString()
- return "\(sign)\(stringFormatted)Bn"
- case 1_000_000...:
- let formatted = num / 1_000_000
- let stringFormatted = formatted.asNumberString()
- return "\(sign)\(stringFormatted)M"
- case 1_000...:
- let formatted = num / 1_000
- let stringFormatted = formatted.asNumberString()
- return "\(sign)\(stringFormatted)K"
- case 0...:
- return self.asNumberString()
- default:
- return "\(sign)\(self)"
- }
- }
- }
- import Foundation
- import SwiftUI
-
- extension UIApplication{
- /// 结束编辑,隐藏键盘
- func endEditing(){
- sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
- }
- }
- 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 = .short
- return formatter
- }
-
- // 转换为字符串短类型
- func asShortDateString() -> String{
- return shortFormatter.string(from: self)
- }
- }
- import Foundation
-
- /// 扩展类 字符串
- extension String{
-
- /// 移除 HTML 内容,查找到 HTML 标记,用 "" 替代
- var removingHTMLOccurances: String{
- return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
- }
- }
- 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()
- })
- }
- }
- import Foundation
- import SwiftUI
- import Combine
-
- /// 货币图片下载缓存服务
- class CoinImageService{
- @Published var image: UIImage? = nil
- // 随时取消操作
- private var imageSubscription: AnyCancellable?
- private let coin: CoinModel
- private let fileManager = LocalFileManager.instance
- private let folderName = "coin_images"
- private let imageName: String
-
- init(coin: CoinModel) {
- self.coin = coin
- self.imageName = coin.id
- getCoinImage()
- }
-
- // 获取图片: 文件夹获取 / 下载
- 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 in
- return UIImage(data: data)
- }
- .receive(on: DispatchQueue.main)
- .sink(receiveCompletion: NetworkingManager.handleCompletion,
- receiveValue: { [weak self] returnedImage in
- guard 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);
- })
- }
- }
- 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()
- })
- }
- }
- 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 in
- if 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.id
- entity.amount = amount
- applyChanges()
- }
-
- /// 更新数据
- private func update(entity: PortfolioEntity, amount: Double){
- entity.amount = amount
- applyChanges()
- }
-
- /// 删除数据
- 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()
- }
- }
- 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 in
- self?.allCoins = returnedCoins
- }
- .store(in: &cancellables)
-
- // 更新持有交易货币投资组合数据
- $allCoins
- // 组合订阅消息
- .combineLatest(portfolioDataService.$savedEntities)
- // 根据投资组合实体中数据,获取持有的货币信息
- .map(mapAllCoinsToPortfolioCoins)
- .sink {[weak self] returnedCoins in
- guard let self = self else { return }
- // 排序
- self.portfolioCoins = self.sortPortfolioCoinsIfNeeded(coins: returnedCoins)
- }
- .store(in: &cancellables)
-
- // 更新市场数据,订阅市场数据服务
- marketDataService.$marketData
- // 组合订阅持有交易货币投资组合的数据
- .combineLatest($portfolioCoins)
- // 转换为统计数据模型数组
- .map(mapGlobalMarketData)
- .sink {[weak self] returnedStats in
- self?.statistics = returnedStats
- self?.isLoading = false
- }
- .store(in: &cancellables)
- }
-
- /// 更新持有交易货币组合投资中的数据
- func updatePortfolio(coin: CoinModel, amount: Double){
- portfolioDataService.updatePortfolio(coin: coin, amount: amount)
- }
-
- /// 重新加载货币数据
- func reloadData(){
- isLoading = true
- coinDataService.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? in
- guard 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 in
- let currentValue = coin.currentHoldingsValue
- let percentChange = (coin.priceChangePercentage24H ?? 0) / 100
- // 假如当前值为: 110,之前24小时上涨了 10%,之前的值为 100
- // 110 / (1 + 0.1) = 100
- let 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
- }
- }
- 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:CoinImageService
- private var cancellable = Set<AnyCancellable>()
-
- init(coin: CoinModel) {
- self.coin = coin
- self.dataService = CoinImageService(coin: coin)
- self.addSubscribers()
- self.isLoading = true
- }
-
- /// 添加订阅者
- private func addSubscribers(){
- dataService.$image
- .sink(receiveCompletion: { [weak self]_ in
- self?.isLoading = false
- }, receiveValue: { [weak self] returnedImage in
- self?.image = returnedImage
- })
- .store(in: &cancellable)
- }
- }
- import SwiftUI
-
- /// 货币图片视图
- struct CoinImageView: View {
- //= CoinImageViewModel(coin: DeveloperPreview.instance.coin)
- @StateObject private var viewModel: CoinImageViewModel
-
- init(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)
- }
- }
- import SwiftUI
-
- /// 货币的图片与名称
- struct CoinLogoView: View {
- let coin: CoinModel
-
- var 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)
- }
- }
- import SwiftUI
-
- /// 带阴影圆形按钮视图
- struct CircleButtonView: View {
- let iconName: String
-
- var 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)
- }
- }
- }
- import SwiftUI
-
- /// 圆形按钮动画视图
- struct CircleButtonAnimationView: View {
- // 是否动画
- @Binding var animate: Bool
-
- var 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)
- }
- }
- import SwiftUI
-
- /// 搜索框视图
- struct SearchBarView: View {
- @Binding var searchText: String
-
- var 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)
- }
- }
- }
- import SwiftUI
-
- /// 统计数据视图
- struct StatisticView: View {
- let stat : StatisticModel
- var 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)
- }
- }
- }
- 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)
- }
- }
- import SwiftUI
-
- /// 主页货币数据统计视图
- struct HomeStatsView: View {
- /// 环境对象,主 ViewModel
- @EnvironmentObject private var viewModel: HomeViewModel
- /// 输出货币统计数据或者持有货币统计数据
- @Binding var showPortfolio: Bool
-
- var body: some View {
- HStack {
- ForEach(viewModel.statistics) { stat in
- StatisticView(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)
- }
- }
- import SwiftUI
-
- /// 货币列表行视图
- struct CoinRowView: View {
- /// 硬币模型
- let coin: CoinModel;
-
- /// 控股列
- let showHoldingsColumn: Bool
-
- var body: some View {
- HStack(spacing: 0) {
- leftColumn
- Spacer()
- if showHoldingsColumn {
- centerColumn
- }
- rightColumn
- }
- .font(.subheadline)
- // 追加热区限制,使 Spacer 也可点击
- //.contentShape(Rectangle())
- // 添加背景,使得 Spacer 也可点击
- .background(Color.theme.background.opacity(0.001))
- }
- }
-
- // 扩展类
- extension CoinRowView{
- // 左边的View
- private 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)
- }
- }
-
- // 中间的View
- private var centerColumn: some View{
- // 显示持有的股份
- VStack(alignment: .trailing) {
- // 显示持有的金额
- Text(coin.currentHoldingsValue.asCurrencyWith2Decimals())
- .bold()
- // 显示我们的持有量
- Text((coin.currentHoldings ?? 0).asNumberString())
- }
- .foregroundColor(Color.theme.accent)
- }
-
- // 右边的View
- private 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)
- }
- }
- }
- 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 = false
-
- var 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 in
- CoinLogoView(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 = coin
- if 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(){
- // 判断是否有选中按钮
- guard
- let coin = selectedCoin,
- let amount = Double(quantityText)
- else { return }
-
- // 保存/更新到持有投资组合货币
- viewModel.updatePortfolio(coin: coin, amount: amount)
-
- // 显示检查标记
- withAnimation(.easeIn) {
- showCheckmark = true
- removeSelectedCoin()
- }
-
- // 隐藏键盘
- 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)
- }
- }
- 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 = false
-
- var body: some View {
- ZStack {
- // 背景布局 background layer
- Color.theme.background
- .ignoresSafeArea()
- // 新的工作表单,持有货币组合 View
- .sheet(isPresented: $showPortfolioView) {
- PortfolioView()
- // 环境变量对象添加 ViewModel
- .environmentObject(viewModel)
- }
-
- // 内容布局
- VStack {
- // 顶部导航栏
- homeHeader
-
- // 统计栏
- HomeStatsView(showPortfolio: $showPortfolio)
-
- // 搜索框
- SearchBarView(searchText: $viewModel.searchText)
-
- // 列表标题栏
- columnTitles
-
- // 货币列表数据
- coinSectionUsingTransitions
-
- //coinSectionUsingOffsets
- Spacer(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{
- // 主页顶部 View
- private 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 in
- CoinRowView(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 in
- CoinRowView(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 = coin
- showDetailView.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)
- }
- }
