HomeView/主页 的实现

1. 创建数据模型

1.1 创建货币模型 CoinModel.swift

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=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]?
}

1.2 创建统计数据模型 StatisticModel.swift

Swift 复制代码
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
    }
}

1.3 创建市场数据模型 MarketDataModel.swift

Swift 复制代码
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 ""
    }
}

1.4 创建核心数据库文件 PortfolioContainer.xcdatamodeld,添加参数如图:

2. 创建工具管理类

2.1 创建网络请求管理器 NetworkingManager.swift

Swift 复制代码
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
        }
    }
}

2.2 创建本地文件管理器 LocalFileManager.swift

Swift 复制代码
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")
    }
}

2.3 创建触觉管理器 HapticManager.swift

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

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

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 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
    )
}

3.3 创建双精度扩展类 Double.swift

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 = 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)"
        }
    }
}

3.4 创建应用扩展类 UIApplication.swift

Swift 复制代码
import Foundation
import SwiftUI

extension UIApplication{
    /// 结束编辑,隐藏键盘
    func endEditing(){
        sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    }
}

3.5 创建日期扩展类 Date.swift

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 = .short
        return formatter
    }
    
    // 转换为字符串短类型
    func asShortDateString() -> String{
        return shortFormatter.string(from: self)
    }
}

3.6 创建字符串扩展类 String.swift

Swift 复制代码
import Foundation

/// 扩展类 字符串
extension String{
 
    /// 移除 HTML 内容,查找到 HTML 标记,用 "" 替代
    var removingHTMLOccurances: String{
        return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
    }
}

4. 创建数据服务类

4.1 创建货币数据服务类 CoinDataService.swift

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

Swift 复制代码
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);
            })
    }
}

4.3 创建市场数据服务类 MarketDataService.swift

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

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 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()
    }
}

5. 创建主页 ViewModel HomeViewModel.swift

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 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
    }
}

6. 视图组件

6.1 货币图片、标志、名称视图组件

1) 创建货币图片 ViewModel CoinImageViewModel.swift
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: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)
    }
}
2) 创建货币图片视图 CoinImageView.swift
Swift 复制代码
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)
    }
}
3) 创建货币图片、标志、名称视图 CoinLogoView.swift
Swift 复制代码
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)
    }
}

6.2 圆形按钮视图组件

1) 创建带阴影圆形按钮视图 CircleButtonView.swift
Swift 复制代码
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)
        }
    }
}
2) 创建圆形按钮动画视图 CircleButtonAnimationView.swift
Swift 复制代码
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)
    }
}

6.3 创建搜索框视图 SearchBarView.swift

Swift 复制代码
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)
        }
    }
}

6.4 创建统计数据视图 StatisticView.swift

Swift 复制代码
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)
        }
    }
}

6.5 创建通用关闭按钮视图 XMarkButton.swift

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

Swift 复制代码
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)
    }
}

7.2 创建货币列表行视图 CoinRowView.swift

Swift 复制代码
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)
        }
    }
}

7.3 创建编辑持有交易货币投资组合视图 PortfolioView.swift

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 = 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)
    }
}

7.4 创建主页视图 HomeView.swift

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 = 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)
    }
}

8. 效果图:

相关推荐
Jouzzy7 小时前
【iOS安全】Dopamine越狱 iPhone X iOS 16.6 (20G75) | 解决Jailbreak failed with error
安全·ios·iphone
瓜子三百克7 小时前
采用sherpa-onnx 实现 ios语音唤起的调研
macos·ios·cocoa
左钦杨8 小时前
IOS CSS3 right transformX 动画卡顿 回弹
前端·ios·css3
努力成为包租婆9 小时前
SDK does not contain ‘libarclite‘ at the path
ios
安和昂1 天前
【iOS】Tagged Pointer
macos·ios·cocoa
I烟雨云渊T1 天前
iOS 阅后即焚功能的实现
macos·ios·cocoa
struggle20251 天前
适用于 iOS 的 开源Ultralytics YOLO:应用程序和 Swift 软件包,用于在您自己的 iOS 应用程序中运行 YOLO
yolo·ios·开源·app·swift
Unlimitedz1 天前
iOS视频编码详细步骤(视频编码器,基于 VideoToolbox,支持硬件编码 H264/H265)
ios·音视频
安和昂2 天前
【iOS】SDWebImage源码学习
学习·ios
ii_best2 天前
按键精灵ios脚本新增元素功能助力辅助工具开发(三)
ios