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

相关推荐
恋猫de小郭9 小时前
什么?Flutter 可能会被 SwiftUI/ArkUI 化?全新的 Flutter Roadmap
flutter·ios·swiftui
网安墨雨13 小时前
iOS应用网络安全之HTTPS
web安全·ios·https
威化饼的一隅13 小时前
【多模态】swift-3框架使用
人工智能·深度学习·大模型·swift·多模态
福大大架构师每日一题14 小时前
37.1 prometheus管理接口源码讲解
ios·iphone·prometheus
BangRaJun1 天前
LNCollectionView-替换幂率流体
算法·ios·设计
刘小哈哈哈1 天前
iOS 多个输入框弹出键盘处理
macos·ios·cocoa
靴子学长1 天前
iOS + watchOS Tourism App(含源码可简单复现)
mysql·ios·swiftui
一如初夏丿2 天前
xcode15 报错 does not contain ‘libarclite‘
ios·xcode
杨武博2 天前
ios 混合开发应用白屏问题
ios
BangRaJun2 天前
LNCollectionView
android·ios·objective-c