• HomeView/主页 的实现


    1. 创建数据模型

      1.1 创建货币模型 CoinModel.swift

    1. import Foundation
    2. // GoinGecko API info
    3. /*
    4. URL:
    5. 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
    6. JSON Response
    7. {
    8. "id": "bitcoin",
    9. "symbol": "btc",
    10. "name": "Bitcoin",
    11. "image": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579",
    12. "current_price": 29594.97,
    13. "market_cap": 575471925043,
    14. "market_cap_rank": 1,
    15. "fully_diluted_valuation": 621468559135,
    16. "total_volume": 17867569837,
    17. "high_24h": 29975,
    18. "low_24h": 28773,
    19. "price_change_24h": 671.94,
    20. "price_change_percentage_24h": 2.32321,
    21. "market_cap_change_24h": 13013242516,
    22. "market_cap_change_percentage_24h": 2.31364,
    23. "circulating_supply": 19445731,
    24. "total_supply": 21000000,
    25. "max_supply": 21000000,
    26. "ath": 69045,
    27. "ath_change_percentage": -57.13833,
    28. "ath_date": "2021-11-10T14:24:11.849Z",
    29. "atl": 67.81,
    30. "atl_change_percentage": 43542.79212,
    31. "atl_date": "2013-07-06T00:00:00.000Z",
    32. "roi": null,
    33. "last_updated": "2023-08-02T07:45:52.912Z",
    34. "sparkline_in_7d": {
    35. "price": [
    36. 29271.02433564558,
    37. 29245.370873051394
    38. ]
    39. },
    40. "price_change_percentage_24h_in_currency": 2.3232080710152045
    41. }
    42. */
    43. /// 硬币模型
    44. struct CoinModel: Identifiable, Codable{
    45. let id, symbol, name: String
    46. let image: String
    47. let currentPrice: Double
    48. let marketCap, marketCapRank, fullyDilutedValuation, totalVolume: Double?
    49. let high24H, low24H: Double?
    50. let priceChange24H, priceChangePercentage24H: Double?
    51. let marketCapChange24H: Double?
    52. let marketCapChangePercentage24H: Double?
    53. let circulatingSupply, totalSupply, maxSupply, ath: Double?
    54. let athChangePercentage: Double?
    55. let athDate: String?
    56. let atl, atlChangePercentage: Double?
    57. let atlDate: String?
    58. let lastUpdated: String?
    59. let sparklineIn7D: SparklineIn7D?
    60. let priceChangePercentage24HInCurrency: Double?
    61. let currentHoldings: Double?
    62. enum CodingKeys: String, CodingKey{
    63. case id, symbol, name, image
    64. case currentPrice = "current_price"
    65. case marketCap = "market_cap"
    66. case marketCapRank = "market_cap_rank"
    67. case fullyDilutedValuation = "fully_diluted_valuation"
    68. case totalVolume = "total_volume"
    69. case high24H = "high_24h"
    70. case low24H = "low_24h"
    71. case priceChange24H = "price_change_24h"
    72. case priceChangePercentage24H = "price_change_percentage_24h"
    73. case marketCapChange24H = "market_cap_change_24h"
    74. case marketCapChangePercentage24H = "market_cap_change_percentage_24h"
    75. case circulatingSupply = "circulating_supply"
    76. case totalSupply = "total_supply"
    77. case maxSupply = "max_supply"
    78. case ath
    79. case athChangePercentage = "ath_change_percentage"
    80. case athDate = "ath_date"
    81. case atl
    82. case atlChangePercentage = "atl_change_percentage"
    83. case atlDate = "atl_date"
    84. case lastUpdated = "last_updated"
    85. case sparklineIn7D = "sparkline_in_7d"
    86. case priceChangePercentage24HInCurrency = "price_change_percentage_24h_in_currency"
    87. case currentHoldings
    88. }
    89. // 更新 currentHoldings
    90. func updateHoldings(amount: Double) -> CoinModel{
    91. 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)
    92. }
    93. // 当前 currentHoldings: 当前持有量 currentPrice: 当前价格
    94. var currentHoldingsValue: Double{
    95. return (currentHoldings ?? 0) * currentPrice
    96. }
    97. // 排名
    98. var rank: Int{
    99. return Int(marketCapRank ?? 0)
    100. }
    101. }
    102. // MARK: - SparklineIn7D
    103. struct SparklineIn7D: Codable{
    104. let price: [Double]?
    105. }

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

    1. import Foundation
    2. /// 统计数据模型
    3. struct StatisticModel: Identifiable{
    4. let id = UUID().uuidString
    5. let title: String
    6. let value: String
    7. let percentageChange: Double?
    8. init(title: String, value: String, percentageChange: Double? = nil){
    9. self.title = title
    10. self.value = value
    11. self.percentageChange = percentageChange
    12. }
    13. }

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

    1. import Foundation
    2. // JSON data:
    3. /*
    4. URL: https://api.coingecko.com/api/v3/global
    5. JSON Response:
    6. {
    7. "data": {
    8. "active_cryptocurrencies": 10034,
    9. "upcoming_icos": 0,
    10. "ongoing_icos": 49,
    11. "ended_icos": 3376,
    12. "markets": 798,
    13. "total_market_cap": {
    14. "btc": 41415982.085551225,
    15. "eth": 660249629.9804014,
    16. "ltc": 14655556681.638193,
    17. "bch": 5134174420.757854,
    18. "bnb": 4974656759.412051,
    19. "eos": 1687970651664.1853,
    20. "xrp": 1955098545449.6555,
    21. "xlm": 8653816219993.665,
    22. "link": 164544407719.89197,
    23. "dot": 243138384158.18213,
    24. "yfi": 188969825.57739097,
    25. "usd": 1208744112847.1863,
    26. "aed": 4439723170208.301,
    27. "ars": 342300135587211.5,
    28. "aud": 1852168274068.648,
    29. "bdt": 131985176291313.28,
    30. "bhd": 455706200496.2936,
    31. "bmd": 1208744112847.1863,
    32. "brl": 5923450525007.624,
    33. "cad": 1621798568577.5525,
    34. "chf": 1055975779400.883,
    35. "clp": 1038432067347017.2,
    36. "cny": 8719154783611.906,
    37. "czk": 26637819261281.18,
    38. "dkk": 8191626216674.328,
    39. "eur": 1099398702910.807,
    40. "gbp": 947401548208.496,
    41. "hkd": 9438393793079.348,
    42. "huf": 426215232621189.9,
    43. "idr": 18399550169412116,
    44. "ils": 4468853903327.898,
    45. "inr": 100074962676574.22,
    46. "jpy": 172903189967437.97,
    47. "krw": 1592952743697798.8,
    48. "kwd": 371735955720.91144,
    49. "lkr": 390986477316809.3,
    50. "mmk": 2534052004053905.5,
    51. "mxn": 20694025572854.312,
    52. "myr": 5532421804501.558,
    53. "ngn": 907911878041781.4,
    54. "nok": 12320972908562.197,
    55. "nzd": 1993476504581.048,
    56. "php": 68066798482650.87,
    57. "pkr": 342404126260727.94,
    58. "pln": 4869997394570.292,
    59. "rub": 115933647966061.98,
    60. "sar": 4534644636646.075,
    61. "sek": 12833723369976.055,
    62. "sgd": 1625841817635.0283,
    63. "thb": 42306043949651.69,
    64. "try": 32662320794122.848,
    65. "twd": 38455675399008.88,
    66. "uah": 44568641287237.47,
    67. "vef": 121031548019.38873,
    68. "vnd": 28690182404226572,
    69. "zar": 22711359059990.625,
    70. "xdr": 902640544965.6523,
    71. "xag": 52235006540.929985,
    72. "xau": 625126192.8411788,
    73. "bits": 41415982085551.23,
    74. "sats": 4141598208555122.5
    75. },
    76. "total_volume": {
    77. "btc": 1370301.588278819,
    78. "eth": 21845217.01679708,
    79. "ltc": 484898138.0297936,
    80. "bch": 169870832.6831974,
    81. "bnb": 164592983.56086707,
    82. "eos": 55848702565.24502,
    83. "xrp": 64686976069.70232,
    84. "xlm": 286322755462.7357,
    85. "link": 5444165558.484416,
    86. "dot": 8044549403.54382,
    87. "yfi": 6252312.249666742,
    88. "usd": 39992869763.07196,
    89. "aed": 146894010604.11282,
    90. "ars": 11325444812447.17,
    91. "aud": 61281394280.91332,
    92. "bdt": 4366901075233.5366,
    93. "bhd": 15077631843.636286,
    94. "bmd": 39992869763.07196,
    95. "brl": 195985058273.93372,
    96. "cad": 53659313204.24844,
    97. "chf": 34938330925.19639,
    98. "clp": 34357874413455.105,
    99. "cny": 288484566748.94366,
    100. "czk": 881346866690.6755,
    101. "dkk": 271030598576.85486,
    102. "eur": 36375034778.56504,
    103. "gbp": 31346011391.598164,
    104. "hkd": 312281524044.0637,
    105. "huf": 14101884847328.004,
    106. "idr": 608773027974562.1,
    107. "ils": 147857838765.40222,
    108. "inr": 3311110189766.445,
    109. "jpy": 5720726731565.593,
    110. "krw": 52704911602318.8,
    111. "kwd": 12299367174.065407,
    112. "lkr": 12936295697541.31,
    113. "mmk": 83842403610359.19,
    114. "mxn": 684688728418.6284,
    115. "myr": 183047364905.5799,
    116. "ngn": 30039444336438.703,
    117. "nok": 407655400054.68567,
    118. "nzd": 65956760720.56524,
    119. "php": 2252078482098.112,
    120. "pkr": 11328885479018.625,
    121. "pln": 161130192467.93414,
    122. "rub": 3835815401278.992,
    123. "sar": 150034610673.73703,
    124. "sek": 424620415401.04956,
    125. "sgd": 53793089353.60598,
    126. "thb": 1399750441707.5242,
    127. "try": 1080675328876.8026,
    128. "twd": 1272355994571.0083,
    129. "uah": 1474611414916.4841,
    130. "vef": 4004486049.3763947,
    131. "vnd": 949251968366005,
    132. "zar": 751434828409.7075,
    133. "xdr": 29865035431.401863,
    134. "xag": 1728263071.944928,
    135. "xau": 20683112.455367908,
    136. "bits": 1370301588278.819,
    137. "sats": 137030158827881.9
    138. },
    139. "market_cap_percentage": {
    140. "btc": 46.96554813023725,
    141. "eth": 18.20564615641025,
    142. "usdt": 6.9030113487818845,
    143. "bnb": 3.0917977469405105,
    144. "xrp": 2.6976159248858225,
    145. "usdc": 2.161451122645245,
    146. "steth": 1.2093198987489995,
    147. "doge": 0.8556120003835122,
    148. "ada": 0.8462977860840838,
    149. "sol": 0.7808186900563315
    150. },
    151. "market_cap_change_percentage_24h_usd": 0.3274584437097279,
    152. "updated_at": 1691478601
    153. }
    154. }
    155. */
    156. // MARK: - Welcome
    157. struct GlobalData: Codable {
    158. let data: MarketDataModel?
    159. }
    160. // MARK: - 市场数据模型
    161. struct MarketDataModel: Codable {
    162. let totalMarketCap, totalVolume, marketCapPercentage: [String: Double]
    163. let marketCapChangePercentage24HUsd: Double
    164. enum CodingKeys: String, CodingKey{
    165. // 总市值
    166. case totalMarketCap = "total_market_cap"
    167. case totalVolume = "total_volume"
    168. case marketCapPercentage = "market_cap_percentage"
    169. case marketCapChangePercentage24HUsd = "market_cap_change_percentage_24h_usd"
    170. }
    171. // 总市值
    172. var marketCap: String{
    173. // 取指定 key 的值 : usd
    174. if let item = totalMarketCap.first(where: {$0.key == "usd"}) {
    175. return "$" + item.value.formattedWithAbbreviations()
    176. }
    177. return ""
    178. }
    179. // 24 小时交易量
    180. var volume: String {
    181. if let item = totalVolume.first(where: {$0.key == "usd"}){
    182. return "$" + item.value.formattedWithAbbreviations()
    183. }
    184. return ""
    185. }
    186. // 比特币占有总市值
    187. var btcDominance: String {
    188. if let item = marketCapPercentage.first(where: {$0.key == "btc"}){
    189. return item.value.asPercentString()
    190. }
    191. return ""
    192. }
    193. }

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

    2. 创建工具管理类

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

    1. import Foundation
    2. import Combine
    3. /// 网络请求管理器
    4. class NetworkingManager{
    5. /// 错误状态
    6. enum NetworkingError: LocalizedError{
    7. case badURLResponse(url: URL)
    8. case unknown
    9. var errorDescription: String?{
    10. switch self {
    11. case .badURLResponse(url: let url): return "[🔥] Bad response from URL: \(url)"
    12. case .unknown: return "[⚠️] Unknown error occured"
    13. }
    14. }
    15. }
    16. /// 下载数据通用方法
    17. static func downLoad(url: URL) -> AnyPublisher<Data, any Error>{
    18. return URLSession.shared.dataTaskPublisher(for: url)
    19. // 默认执行的操作,确保在后台执行线程上
    20. //.subscribe(on: DispatchQueue.global(qos: .default))
    21. .tryMap({ try handleURLResponse(output: $0, url: url) })
    22. //.receive(on: DispatchQueue.main)
    23. // 重试次数
    24. .retry(3)
    25. .eraseToAnyPublisher()
    26. }
    27. /// 返回状态/数据通用方法 throws: 抛出异常
    28. static func handleURLResponse(output: URLSession.DataTaskPublisher.Output, url: URL)throws -> Data{
    29. guard let response = output.response as? HTTPURLResponse,
    30. response.statusCode >= 200 && response.statusCode < 300 else {
    31. // URLError(.badServerResponse)
    32. throw NetworkingError.badURLResponse(url: url)
    33. }
    34. return output.data
    35. }
    36. /// 返回完成/失败通用方法
    37. static func handleCompletion(completion: Subscribers.Completion<Error>){
    38. switch completion{
    39. case .finished:
    40. break
    41. case .failure(let error):
    42. print(error.localizedDescription)
    43. break
    44. }
    45. }
    46. }

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

    1. import Foundation
    2. import SwiftUI
    3. /// 本地文件管理器
    4. class LocalFileManager{
    5. // 单例模式
    6. static let instance = LocalFileManager()
    7. // 保证应用程序中只有一个实例并且只能在内部实例化
    8. private init() {}
    9. // 保存图片
    10. func saveImage(image: UIImage, imageName: String, folderName: String) {
    11. // 创建文件夹路径
    12. createFolderIfNeeded(folderName: folderName)
    13. // 获取图片的路径
    14. guard
    15. let data = image.pngData(),
    16. let url = getURLForImage(imageName: imageName, folderName: folderName)
    17. else { return }
    18. // 保存文件到指定的文件夹
    19. do{
    20. try data.write(to: url)
    21. }catch let error{
    22. print("Error saving image. Image name \(imageName).| \(error.localizedDescription)")
    23. }
    24. }
    25. // 获取图片
    26. func getImage(imageName: String, folderName: String) -> UIImage?{
    27. guard
    28. let url = getURLForImage(imageName: imageName, folderName: folderName),
    29. FileManager.default.fileExists(atPath: url.path)else {
    30. return nil
    31. }
    32. return UIImage(contentsOfFile: url.path)
    33. }
    34. /// 创建文件夹路径
    35. private func createFolderIfNeeded(folderName: String){
    36. guard let url = getURLForFolder(folderName: folderName) else { return }
    37. if !FileManager.default.fileExists(atPath: url.path){
    38. do {
    39. try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
    40. } catch let error {
    41. print("Error creating directory. Folder name \(folderName).| \(error.localizedDescription)")
    42. }
    43. }
    44. }
    45. /// 获取文件夹路径
    46. private func getURLForFolder(folderName: String) -> URL? {
    47. guard let url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil}
    48. return url.appendingPathComponent(folderName)
    49. }
    50. /// 获取图片的路径
    51. private func getURLForImage(imageName: String, folderName: String) -> URL?{
    52. guard let folderURL = getURLForFolder(folderName: folderName) else { return nil }
    53. return folderURL.appendingPathComponent(imageName + ".png")
    54. }
    55. }

      2.3 创建触觉管理器 HapticManager.swift

    1. import Foundation
    2. import SwiftUI
    3. /// 触觉管理器
    4. class HapticManager{
    5. /// 通知反馈生成器器
    6. static private let generator = UINotificationFeedbackGenerator()
    7. /// 通知: 反馈类型
    8. static func notification(type: UINotificationFeedbackGenerator.FeedbackType){
    9. generator.notificationOccurred(type)
    10. }
    11. }

    3. 创建扩展类

      3.1 创建颜色扩展类 Color.swift

    1. import Foundation
    2. import SwiftUI
    3. /// 扩展类 颜色
    4. extension Color{
    5. static let theme = ColorTheme()
    6. static let launch = LaunchTheme()
    7. }
    8. /// 颜色样式
    9. struct ColorTheme{
    10. let accent = Color("AccentColor")
    11. let background = Color("BackgroundColor")
    12. let green = Color("GreenColor")
    13. let red = Color("RedColor")
    14. let secondaryText = Color("SecondaryTextColor")
    15. }
    16. /// 颜色样式2
    17. struct ColorTheme2{
    18. let accent = Color(#colorLiteral(red: 0, green: 0.9914394021, blue: 1, alpha: 1))
    19. let background = Color(#colorLiteral(red: 0.09019608051, green: 0, blue: 0.3019607961, alpha: 1))
    20. let green = Color(#colorLiteral(red: 0, green: 0.5603182912, blue: 0, alpha: 1))
    21. let red = Color(#colorLiteral(red: 0.5807225108, green: 0.066734083, blue: 0, alpha: 1))
    22. let secondaryText = Color(#colorLiteral(red: 0.7540688515, green: 0.7540867925, blue: 0.7540771365, alpha: 1))
    23. }
    24. /// 启动样式
    25. struct LaunchTheme {
    26. let accent = Color("LaunchAccentColor")
    27. let background = Color("LaunchBackgroundColor")
    28. }

      3.2 创建提供预览视图扩展类 PreviewProvider.swift

    1. import Foundation
    2. import SwiftUI
    3. /// 扩展类 提供预览
    4. extension PreviewProvider{
    5. // 开发者预览数据
    6. static var dev: DeveloperPreview{
    7. return DeveloperPreview.instance
    8. }
    9. }
    10. // 开发者预览版
    11. class DeveloperPreview{
    12. // 单例模式
    13. static let instance = DeveloperPreview()
    14. private init() {}
    15. // 环境变量,呈现的模式:显示或者关闭
    16. @Environment(\.presentationMode) var presentationMode
    17. let homeViewModel = HomeViewModel()
    18. // 统计数据模型
    19. let stat1 = StatisticModel(title: "Market Cap", value: "$12.5Bn", percentageChange: 26.32)
    20. let stat2 = StatisticModel(title: "Total Volume", value: "$1.23Tr")
    21. let stat3 = StatisticModel(title: "Portfolio Value", value: "$50.4k",percentageChange: -12.32)
    22. let coin = CoinModel(
    23. id: "bitcoin",
    24. symbol: "btc",
    25. name: "Bitcoin",
    26. image: "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579",
    27. currentPrice: 29594.97,
    28. marketCap: 575471925043,
    29. marketCapRank: 1,
    30. fullyDilutedValuation: 621468559135,
    31. totalVolume: 17867569837,
    32. high24H: 29975,
    33. low24H: 28773,
    34. priceChange24H: 671.94,
    35. priceChangePercentage24H: 2.32321,
    36. marketCapChange24H: 13013242516,
    37. marketCapChangePercentage24H: 2.31364,
    38. circulatingSupply: 19445731,
    39. totalSupply: 21000000,
    40. maxSupply: 21000000,
    41. ath: 69045,
    42. athChangePercentage: -57.13833,
    43. athDate: "2021-11-10T14:24:11.849Z",
    44. atl: 67.81,
    45. atlChangePercentage: 43542.79212,
    46. atlDate: "2013-07-06T00:00:00.000Z",
    47. lastUpdated: "2023-08-02T07:45:52.912Z",
    48. sparklineIn7D:
    49. SparklineIn7D(price:[
    50. 29271.02433564558,
    51. 29245.370873051394,
    52. 29205.501195094886,
    53. 29210.97710800848,
    54. 29183.90996906209,
    55. 29191.187134377586,
    56. 29167.309535190096,
    57. 29223.071887272858,
    58. 29307.753433422175,
    59. 29267.687825355235,
    60. 29313.499192934243,
    61. 29296.218518715148,
    62. 29276.651666477588,
    63. 29343.71801186576,
    64. 29354.73988657794,
    65. 29614.69857297837,
    66. 29473.762709346545,
    67. 29460.63779255003,
    68. 29363.672907978616,
    69. 29325.29799021886,
    70. 29370.611267446548,
    71. 29390.15178296929,
    72. 29428.222505493162,
    73. 29475.12359313808,
    74. 29471.20179209623,
    75. 29396.682959470276,
    76. 29416.063748693945,
    77. 29442.757895685798,
    78. 29550.523558342804,
    79. 29489.241437118748,
    80. 29513.005452237085,
    81. 29481.87017389305,
    82. 29440.157241806293,
    83. 29372.682404809886,
    84. 29327.962010819112,
    85. 29304.689279369806,
    86. 29227.558442049805,
    87. 29178.745455204324,
    88. 29155.348160823945,
    89. 29146.414472358578,
    90. 29190.04784447575,
    91. 29200.962573823388,
    92. 29201.236356821602,
    93. 29271.258206136354,
    94. 29276.093243553125,
    95. 29193.96481135078,
    96. 29225.130187030347,
    97. 29259.34141509108,
    98. 29172.589866912043,
    99. 29177.057442352412,
    100. 29144.25689537892,
    101. 29158.76207558714,
    102. 29202.314532690547,
    103. 29212.0966881263,
    104. 29222.654794248145,
    105. 29302.58488156929,
    106. 29286.271181422144,
    107. 29437.329605975596,
    108. 29387.54866090718,
    109. 29374.800526401574,
    110. 29237.366870488135,
    111. 29306.414045617796,
    112. 29313.493330593126,
    113. 29329.5049157853,
    114. 29317.998848911364,
    115. 29300.313958408336,
    116. 29314.09738709836,
    117. 29331.597426309774,
    118. 29372.858006614388,
    119. 29371.93585447968,
    120. 29365.560710924212,
    121. 29386.997851302443,
    122. 29357.263814441514,
    123. 29344.33621803127,
    124. 29307.866330609653,
    125. 29292.411501323997,
    126. 29279.062208908184,
    127. 29290.907121380646,
    128. 29275.952127727414,
    129. 29296.397048693474,
    130. 29300.218227669986,
    131. 29291.762204217895,
    132. 29291.877166187365,
    133. 29301.25798859754,
    134. 29323.60843299231,
    135. 29305.311033785278,
    136. 29335.43442901468,
    137. 29355.10941623317,
    138. 29350.104456680947,
    139. 29355.533727400776,
    140. 29356.74774591667,
    141. 29337.06524643115,
    142. 29327.210034664997,
    143. 29313.84510272745,
    144. 29316.494745597563,
    145. 29323.673091844805,
    146. 29314.269726879855,
    147. 29276.735658617326,
    148. 29291.429686285876,
    149. 29294.892488066977,
    150. 29281.92132540751,
    151. 29254.767133836835,
    152. 29280.924410272044,
    153. 29317.606859109263,
    154. 29277.34170421034,
    155. 29333.335435295256,
    156. 29377.387821327997,
    157. 29372.791590384797,
    158. 29380.712873208802,
    159. 29357.07852007383,
    160. 29173.883400452203,
    161. 29182.94706943146,
    162. 29210.311445584994,
    163. 29158.20830261118,
    164. 29277.755810272716,
    165. 29454.950860223915,
    166. 29446.040153631897,
    167. 29480.745288051072,
    168. 29419.437853166743,
    169. 29398.450179898642,
    170. 29381.999704403723,
    171. 29401.478326800752,
    172. 29379.291090327082,
    173. 29385.90384828296,
    174. 29370.640322724914,
    175. 29371.859549109304,
    176. 29389.802582833345,
    177. 29449.090796832406,
    178. 29351.411076211785,
    179. 29301.70086480563,
    180. 29250.006595240662,
    181. 29244.84298676968,
    182. 29217.38857006191,
    183. 29197.54498742039,
    184. 29220.005552322902,
    185. 29217.05529059147,
    186. 29239.485487664628,
    187. 29208.638675444134,
    188. 29225.78903990318,
    189. 29283.257482890982,
    190. 29196.40491920269,
    191. 28933.589441398828,
    192. 28836.362892634166,
    193. 28859.850682516564,
    194. 28902.83342032919,
    195. 28923.047091180444,
    196. 28922.768533406037,
    197. 28950.689444814736,
    198. 28926.692827318147,
    199. 28914.78045754031,
    200. 28876.0727583824,
    201. 28873.94607766258,
    202. 28878.68936584147,
    203. 28811.350317624612,
    204. 28893.17367623834,
    205. 28904.107217880563,
    206. 28932.211442017186,
    207. 29162.211547116116,
    208. 29257.225510262706,
    209. 29220.838459786457,
    210. 29190.624191620474,
    211. 29199.152902607395,
    212. 29694.16407843016,
    213. 29772.298033304203,
    214. 29874.280259270647,
    215. 29824.984567470103,
    216. 29613.437605238618,
    217. 29654.778753257848
    218. ]),
    219. priceChangePercentage24HInCurrency: 2.3232080710152045,
    220. currentHoldings: 1.5
    221. )
    222. }

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

    1. import Foundation
    2. /// 扩展类 双精度
    3. extension Double{
    4. /// 双精度数值转换为 小数点为 2位的货币值
    5. /// ```
    6. /// Convert 1234.56 to $1,234.56
    7. /// ```
    8. private var currencyFormatter2: NumberFormatter{
    9. let formatter = NumberFormatter()
    10. // 分组分隔符
    11. formatter.usesGroupingSeparator = true
    12. // 数字格式 等于 货币
    13. formatter.numberStyle = .currency
    14. // 发生时间 为当前 default
    15. //formatter.locale = .current // <- default value
    16. // 当前货币代码 设置为美元 default
    17. //formatter.currencyCode = "usd" // <- change currency
    18. // 当前货币符号 default
    19. //formatter.currencySymbol = "$" // <- change currency symbol
    20. // 最小分数位数
    21. formatter.minimumFractionDigits = 2
    22. // 最大分数位数
    23. formatter.maximumFractionDigits = 2
    24. return formatter
    25. }
    26. /// 双精度数值转换为 字符串类型 小数点为 2位的货币值
    27. /// ```
    28. /// Convert 1234.56 to "$1,234.56"
    29. /// ```
    30. func asCurrencyWith2Decimals() -> String{
    31. let number = NSNumber(value: self)
    32. return currencyFormatter2.string(from: number) ?? "$0.00"
    33. }
    34. /// 双精度数值转换为 小数点为 2位到 6位的货币值
    35. /// ```
    36. /// Convert 1234.56 to $1,234.56
    37. /// Convert 12.3456 to $12.3456
    38. /// Convert 0.123456 to $0.123456
    39. /// ```
    40. private var currencyFormatter6: NumberFormatter{
    41. let formatter = NumberFormatter()
    42. // 分组分隔符
    43. formatter.usesGroupingSeparator = true
    44. // 数字格式 等于 货币
    45. formatter.numberStyle = .currency
    46. // 发生时间 为当前 default
    47. //formatter.locale = .current // <- default value
    48. // 当前货币代码 设置为美元 default
    49. //formatter.currencyCode = "usd" // <- change currency
    50. // 当前货币符号 default
    51. //formatter.currencySymbol = "$" // <- change currency symbol
    52. // 最小分数位数
    53. formatter.minimumFractionDigits = 2
    54. // 最大分数位数
    55. formatter.maximumFractionDigits = 6
    56. return formatter
    57. }
    58. /// 双精度数值转换为 字符串类型 小数点为 2位到 6位的货币值
    59. /// ```
    60. /// Convert 1234.56 to "$1,234.56"
    61. /// Convert 12.3456 to "$12.3456"
    62. /// Convert 0.123456 to "$0.123456"
    63. /// ```
    64. func asCurrencyWith6Decimals() -> String{
    65. let number = NSNumber(value: self)
    66. return currencyFormatter6.string(from: number) ?? "$0.00"
    67. }
    68. /// 双精度数值转换为 字符串表现形式
    69. /// ```
    70. /// Convert 1.23456 to "1.23"
    71. /// ```
    72. func asNumberString() -> String{
    73. return String(format: "%.2f", self)
    74. }
    75. /// 双精度数值转换为 字符串表现形式带有百分比符号
    76. /// ```
    77. /// Convert 1.23456 to "1.23%"
    78. /// ```
    79. func asPercentString() -> String {
    80. return asNumberString() + "%"
    81. }
    82. /// Convert a Double to a String with K, M, Bn, Tr abbreviations.
    83. /// k : 千, m : 百万, bn : 十亿,Tr : 万亿
    84. /// ```
    85. /// Convert 12 to 12.00
    86. /// Convert 1234 to 1.23K
    87. /// Convert 123456 to 123.45K
    88. /// Convert 12345678 to 12.34M
    89. /// Convert 1234567890 to 1.23Bn
    90. /// Convert 123456789012 to 123.45Bn
    91. /// Convert 12345678901234 to 12.34Tr
    92. /// ```
    93. func formattedWithAbbreviations() -> String {
    94. let num = abs(Double(self))
    95. let sign = (self < 0) ? "-" : ""
    96. switch num {
    97. case 1_000_000_000_000...:
    98. let formatted = num / 1_000_000_000_000
    99. let stringFormatted = formatted.asNumberString()
    100. return "\(sign)\(stringFormatted)Tr"
    101. case 1_000_000_000...:
    102. let formatted = num / 1_000_000_000
    103. let stringFormatted = formatted.asNumberString()
    104. return "\(sign)\(stringFormatted)Bn"
    105. case 1_000_000...:
    106. let formatted = num / 1_000_000
    107. let stringFormatted = formatted.asNumberString()
    108. return "\(sign)\(stringFormatted)M"
    109. case 1_000...:
    110. let formatted = num / 1_000
    111. let stringFormatted = formatted.asNumberString()
    112. return "\(sign)\(stringFormatted)K"
    113. case 0...:
    114. return self.asNumberString()
    115. default:
    116. return "\(sign)\(self)"
    117. }
    118. }
    119. }

      3.4 创建应用扩展类 UIApplication.swift

    1. import Foundation
    2. import SwiftUI
    3. extension UIApplication{
    4. /// 结束编辑,隐藏键盘
    5. func endEditing(){
    6. sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
    7. }
    8. }

      3.5 创建日期扩展类 Date.swift

    1. import Foundation
    2. /// 扩展类 日期
    3. extension Date {
    4. // "2021-11-10T14:24:11.849Z"
    5. init(coinGeckoString: String) {
    6. let formatter = DateFormatter()
    7. formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
    8. // 指定日期格式转换
    9. let date = formatter.date(from: coinGeckoString) ?? Date()
    10. self.init(timeInterval: 0, since: date)
    11. }
    12. // 输出短格式
    13. private var shortFormatter: DateFormatter{
    14. let formatter = DateFormatter()
    15. formatter.dateStyle = .short
    16. return formatter
    17. }
    18. // 转换为字符串短类型
    19. func asShortDateString() -> String{
    20. return shortFormatter.string(from: self)
    21. }
    22. }

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

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

    4. 创建数据服务类

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

    1. import Foundation
    2. import Combine
    3. /// 货币数据服务
    4. class CoinDataService{
    5. // 硬币模型数组 Published: 可以拥有订阅者
    6. @Published var allCoins: [CoinModel] = []
    7. // 随时取消操作
    8. var coinSubscription: AnyCancellable?
    9. init() {
    10. getCoins()
    11. }
    12. // 获取全部硬币
    13. func getCoins(){
    14. 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")
    15. else { return }
    16. coinSubscription = NetworkingManager.downLoad(url: url)
    17. .decode(type: [CoinModel].self, decoder: JSONDecoder())
    18. .receive(on: DispatchQueue.main)
    19. .sink(receiveCompletion: NetworkingManager.handleCompletion,
    20. receiveValue: { [weak self] returnCoins in
    21. // 解除强引用 (注意)
    22. self?.allCoins = returnCoins
    23. // 取消订阅者
    24. self?.coinSubscription?.cancel()
    25. })
    26. }
    27. }

      4.2 创建货币图片下载缓存服务类 CoinImageService.swift

    1. import Foundation
    2. import SwiftUI
    3. import Combine
    4. /// 货币图片下载缓存服务
    5. class CoinImageService{
    6. @Published var image: UIImage? = nil
    7. // 随时取消操作
    8. private var imageSubscription: AnyCancellable?
    9. private let coin: CoinModel
    10. private let fileManager = LocalFileManager.instance
    11. private let folderName = "coin_images"
    12. private let imageName: String
    13. init(coin: CoinModel) {
    14. self.coin = coin
    15. self.imageName = coin.id
    16. getCoinImage()
    17. }
    18. // 获取图片: 文件夹获取 / 下载
    19. private func getCoinImage(){
    20. // 获取图片
    21. if let saveImage = fileManager.getImage(imageName: imageName, folderName: folderName){
    22. image = saveImage
    23. //print("Retrieved image from file manager!")
    24. }else{
    25. downloadCoinImage()
    26. //print("Downloading image now")
    27. }
    28. }
    29. // 下载硬币的图片
    30. private func downloadCoinImage(){
    31. guard let url = URL(string: coin.image)
    32. else { return }
    33. imageSubscription = NetworkingManager.downLoad(url: url)
    34. .tryMap{ data in
    35. return UIImage(data: data)
    36. }
    37. .receive(on: DispatchQueue.main)
    38. .sink(receiveCompletion: NetworkingManager.handleCompletion,
    39. receiveValue: { [weak self] returnedImage in
    40. guard let self = self, let downloadedImage = returnedImage else { return }
    41. // 解除强引用 (注意)
    42. self.image = downloadedImage
    43. // 取消订阅者
    44. self.imageSubscription?.cancel()
    45. // 保存图片
    46. self.fileManager.saveImage(image: downloadedImage, imageName: self.imageName, folderName: self.folderName);
    47. })
    48. }
    49. }

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

    1. import Foundation
    2. import Combine
    3. /// 市场数据服务
    4. class MarketDataService{
    5. // 市场数据模型数组 Published: 可以拥有订阅者
    6. @Published var marketData: MarketDataModel? = nil
    7. // 随时取消操作
    8. var marketDataSubscription: AnyCancellable?
    9. init() {
    10. getData()
    11. }
    12. // 获取全部硬币
    13. func getData(){
    14. guard let url = URL(string: "https://api.coingecko.com/api/v3/global") else { return }
    15. marketDataSubscription = NetworkingManager.downLoad(url: url)
    16. .decode(type: GlobalData.self, decoder: JSONDecoder())
    17. .receive(on: DispatchQueue.main)
    18. .sink(receiveCompletion: NetworkingManager.handleCompletion,
    19. receiveValue: { [weak self] returnGlobalData in
    20. // 解除强引用 (注意)
    21. self?.marketData = returnGlobalData.data
    22. // 取消订阅者
    23. self?.marketDataSubscription?.cancel()
    24. })
    25. }
    26. }

      4.4 创建持有交易货币投资组合数据存储服务(核心数据存储) PortfolioDataService.swift

    1. import Foundation
    2. import CoreData
    3. /// 持有交易货币投资组合数据存储服务(核心数据存储)
    4. class PortfolioDataService{
    5. // 数据容器
    6. private let container: NSPersistentContainer
    7. // 容器名称
    8. private let containerName: String = "PortfolioContainer"
    9. // 实体名称
    10. private let entityName: String = "PortfolioEntity"
    11. // 投资组合实体集合
    12. @Published var savedEntities: [PortfolioEntity] = []
    13. init() {
    14. // 获取容器文件
    15. container = NSPersistentContainer(name: containerName)
    16. // 加载持久存储
    17. container.loadPersistentStores { _, error in
    18. if let error = error {
    19. print("Error loading core data! \(error)")
    20. }
    21. self.getPortfolio()
    22. }
    23. }
    24. // MARK: PUBLIC
    25. // 公开方法
    26. /// 更新 / 删除 / 添加 投资组合数据
    27. func updatePortfolio(coin: CoinModel, amount: Double){
    28. // 判断货币数据是否在投资组合实体集合中
    29. if let entity = savedEntities.first(where: {$0.coinID == coin.id}){
    30. // 存在则更新
    31. if amount > 0{
    32. update(entity: entity, amount: amount)
    33. }else{
    34. delete(entity: entity)
    35. }
    36. }else{
    37. add(coin: coin, amount: amount)
    38. }
    39. }
    40. // MARK: PRIVATE
    41. // 私有方法
    42. /// 获取容器里的投资组合实体数据
    43. private func getPortfolio(){
    44. // 根据实体名称,获取实体类型
    45. let request = NSFetchRequest<PortfolioEntity>(entityName: entityName)
    46. do {
    47. savedEntities = try container.viewContext.fetch(request)
    48. } catch let error {
    49. print("Error fatching portfolio entities. \(error)")
    50. }
    51. }
    52. /// 添加数据
    53. private func add(coin: CoinModel, amount: Double){
    54. let entity = PortfolioEntity(context: container.viewContext)
    55. entity.coinID = coin.id
    56. entity.amount = amount
    57. applyChanges()
    58. }
    59. /// 更新数据
    60. private func update(entity: PortfolioEntity, amount: Double){
    61. entity.amount = amount
    62. applyChanges()
    63. }
    64. /// 删除数据
    65. private func delete(entity: PortfolioEntity){
    66. container.viewContext.delete(entity)
    67. applyChanges()
    68. }
    69. /// 共用保存方法
    70. private func save(){
    71. do {
    72. try container.viewContext.save()
    73. } catch let error {
    74. print("Error saving to core data. \(error)")
    75. }
    76. }
    77. // 应用并且改变
    78. private func applyChanges(){
    79. save()
    80. getPortfolio()
    81. }
    82. }

    5. 创建主页 ViewModel HomeViewModel.swift

    1. import Foundation
    2. import Combine
    3. /// 主页 ViewModel
    4. class HomeViewModel: ObservableObject{
    5. /// 统计数据模型数组
    6. @Published var statistics: [StatisticModel] = []
    7. /// 硬币模型数组
    8. @Published var allCoins: [CoinModel] = []
    9. /// 持有交易货币投资组合模型数组
    10. @Published var portfolioCoins: [CoinModel] = []
    11. /// 是否重新加载数据
    12. @Published var isLoading: Bool = false
    13. /// 搜索框文本
    14. @Published var searchText: String = ""
    15. /// 默认排序方式为持有最多的交易货币
    16. @Published var sortOption: SortOption = .holdings
    17. /// 货币数据服务
    18. private let coinDataService = CoinDataService()
    19. /// 市场数据请求服务
    20. private let marketDataService = MarketDataService()
    21. /// 持有交易货币投资组合数据存储服务(核心数据存储)
    22. private let portfolioDataService = PortfolioDataService()
    23. /// 随时取消集合
    24. private var cancellables = Set<AnyCancellable>()
    25. /// 排序选项
    26. enum SortOption {
    27. case rank, rankReversed, holdings, holdingsReversed, price, priceReversed
    28. }
    29. init(){
    30. addSubscribers()
    31. }
    32. // 添加订阅者
    33. func addSubscribers(){
    34. // 更新货币消息
    35. $searchText
    36. // 组合订阅消息
    37. .combineLatest(coinDataService.$allCoins, $sortOption)
    38. // 运行其余代码之前等待 0.5 秒、文本框输入停下来之后,停顿 0.5 秒后,再执行后面的操作
    39. .debounce(for: .seconds(0.5), scheduler: DispatchQueue.main)
    40. .map(filterAndSortCoins)
    41. .sink {[weak self] returnedCoins in
    42. self?.allCoins = returnedCoins
    43. }
    44. .store(in: &cancellables)
    45. // 更新持有交易货币投资组合数据
    46. $allCoins
    47. // 组合订阅消息
    48. .combineLatest(portfolioDataService.$savedEntities)
    49. // 根据投资组合实体中数据,获取持有的货币信息
    50. .map(mapAllCoinsToPortfolioCoins)
    51. .sink {[weak self] returnedCoins in
    52. guard let self = self else { return }
    53. // 排序
    54. self.portfolioCoins = self.sortPortfolioCoinsIfNeeded(coins: returnedCoins)
    55. }
    56. .store(in: &cancellables)
    57. // 更新市场数据,订阅市场数据服务
    58. marketDataService.$marketData
    59. // 组合订阅持有交易货币投资组合的数据
    60. .combineLatest($portfolioCoins)
    61. // 转换为统计数据模型数组
    62. .map(mapGlobalMarketData)
    63. .sink {[weak self] returnedStats in
    64. self?.statistics = returnedStats
    65. self?.isLoading = false
    66. }
    67. .store(in: &cancellables)
    68. }
    69. /// 更新持有交易货币组合投资中的数据
    70. func updatePortfolio(coin: CoinModel, amount: Double){
    71. portfolioDataService.updatePortfolio(coin: coin, amount: amount)
    72. }
    73. /// 重新加载货币数据
    74. func reloadData(){
    75. isLoading = true
    76. coinDataService.getCoins()
    77. marketDataService.getData()
    78. // 添加触动提醒
    79. HapticManager.notification(type: .success)
    80. }
    81. /// 过滤器和排序方法
    82. private func filterAndSortCoins(text: String, coins: [CoinModel], sort: SortOption) -> [CoinModel] {
    83. // 过滤
    84. var updatedCoins = filterCoins(text: text, coins: coins)
    85. // 排序
    86. sortCoins(sort: sort, coins: &updatedCoins)
    87. return updatedCoins
    88. }
    89. /// 过滤器方法
    90. private func filterCoins(text: String, coins:[CoinModel]) -> [CoinModel]{
    91. guard !text.isEmpty else{
    92. // 为空返回原数组
    93. return coins
    94. }
    95. // 文本转小写
    96. let lowercasedText = text.lowercased()
    97. // 过滤器
    98. return coins.filter { coin -> Bool in
    99. // 过滤条件
    100. return coin.name.lowercased().contains(lowercasedText) ||
    101. coin.symbol.lowercased().contains(lowercasedText) ||
    102. coin.id.lowercased().contains(lowercasedText)
    103. }
    104. }
    105. /// 排序方法 inout: 基于原有的数组上进行改变
    106. private func sortCoins(sort: SortOption, coins: inout [CoinModel]) {
    107. switch sort {
    108. case .rank, .holdings:
    109. coins.sort(by: { $0.rank < $1.rank })
    110. case .rankReversed, .holdingsReversed:
    111. coins.sort(by: { $0.rank > $1.rank })
    112. case .price:
    113. coins.sort(by: { $0.currentPrice > $1.currentPrice })
    114. case .priceReversed:
    115. coins.sort(by: { $0.currentPrice < $1.currentPrice })
    116. }
    117. }
    118. /// 排序持有的交易货币
    119. private func sortPortfolioCoinsIfNeeded(coins: [CoinModel]) -> [CoinModel]{
    120. // 只会按持有金额高到低或者低到高进行
    121. switch sortOption {
    122. case .holdings:
    123. return coins.sorted(by: { $0.currentHoldingsValue > $1.currentHoldingsValue })
    124. case .holdingsReversed:
    125. return coins.sorted(by: { $0.currentHoldingsValue < $1.currentHoldingsValue })
    126. default:
    127. return coins
    128. }
    129. }
    130. ///在交易货币集合中,根据投资组合实体中数据,获取持有的货币信息
    131. private func mapAllCoinsToPortfolioCoins(allCoins: [CoinModel], portfolioEntities: [PortfolioEntity]) -> [CoinModel]{
    132. allCoins
    133. .compactMap { coin -> CoinModel? in
    134. guard let entity = portfolioEntities.first(where: {$0.coinID == coin.id}) else {
    135. return nil
    136. }
    137. return coin.updateHoldings(amount: entity.amount)
    138. }
    139. }
    140. ///市场数据模型 转换为 统计数据模型数组
    141. private func mapGlobalMarketData(marketDataModel: MarketDataModel?, portfolioCoins: [CoinModel]) -> [StatisticModel]{
    142. // 生成统计数据模型数组
    143. var stats: [StatisticModel] = []
    144. // 检测是否有数据
    145. guard let data = marketDataModel else{
    146. return stats
    147. }
    148. // 总市值
    149. let marketCap = StatisticModel(title: "Market Cap", value: data.marketCap, percentageChange: data.marketCapChangePercentage24HUsd)
    150. // 24 小时交易量
    151. let volume = StatisticModel(title: "24h Volume", value: data.volume)
    152. // 比特币占有总市值
    153. let btcDominance = StatisticModel(title: "BTC Dominance", value: data.btcDominance)
    154. // 持有交易货币的金额
    155. let portfolioValue =
    156. portfolioCoins
    157. .map({ $0.currentHoldingsValue })
    158. // 集合快速求和
    159. .reduce(0, +)
    160. // 持有交易货币的增长率
    161. // 之前的变化价格 24小时
    162. let previousValue =
    163. portfolioCoins
    164. .map { coin -> Double in
    165. let currentValue = coin.currentHoldingsValue
    166. let percentChange = (coin.priceChangePercentage24H ?? 0) / 100
    167. // 假如当前值为: 110,之前24小时上涨了 10%,之前的值为 100
    168. // 110 / (1 + 0.1) = 100
    169. let previousValue = currentValue / (1 + percentChange)
    170. return previousValue
    171. }
    172. .reduce(0, +)
    173. //* 100 百分比 (* 100 : 0.1 -> 10%)
    174. let percentageChange = ((portfolioValue - previousValue) / previousValue) * 100
    175. // 持有的交易货币金额与增长率
    176. let portfolio = StatisticModel(
    177. title: "Portfolio Value",
    178. value: portfolioValue.asCurrencyWith2Decimals(),
    179. percentageChange: percentageChange)
    180. // 添加到数组
    181. stats.append(contentsOf: [
    182. marketCap,
    183. volume,
    184. btcDominance,
    185. portfolio
    186. ])
    187. return stats
    188. }
    189. }

    6. 视图组件

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

        1) 创建货币图片 ViewModel CoinImageViewModel.swift
    1. import Foundation
    2. import SwiftUI
    3. import Combine
    4. /// 货币图片 ViewModel
    5. class CoinImageViewModel: ObservableObject{
    6. @Published var image: UIImage? = nil
    7. @Published var isLoading: Bool = true
    8. /// 货币模型
    9. private let coin: CoinModel
    10. /// 货币图片下载缓存服务
    11. private let dataService:CoinImageService
    12. private var cancellable = Set<AnyCancellable>()
    13. init(coin: CoinModel) {
    14. self.coin = coin
    15. self.dataService = CoinImageService(coin: coin)
    16. self.addSubscribers()
    17. self.isLoading = true
    18. }
    19. /// 添加订阅者
    20. private func addSubscribers(){
    21. dataService.$image
    22. .sink(receiveCompletion: { [weak self]_ in
    23. self?.isLoading = false
    24. }, receiveValue: { [weak self] returnedImage in
    25. self?.image = returnedImage
    26. })
    27. .store(in: &cancellable)
    28. }
    29. }
        2) 创建货币图片视图 CoinImageView.swift
    1. import SwiftUI
    2. /// 货币图片视图
    3. struct CoinImageView: View {
    4. //= CoinImageViewModel(coin: DeveloperPreview.instance.coin)
    5. @StateObject private var viewModel: CoinImageViewModel
    6. init(coin: CoinModel) {
    7. _viewModel = StateObject(wrappedValue: CoinImageViewModel(coin: coin))
    8. }
    9. // 内容
    10. var body: some View {
    11. ZStack {
    12. if let image = viewModel.image {
    13. Image(uiImage: image)
    14. .resizable()
    15. // 缩放适应该视图的任何大小
    16. .scaledToFit()
    17. }else if viewModel.isLoading{
    18. ProgressView()
    19. }else{
    20. Image(systemName: "questionmark")
    21. .foregroundColor(Color.theme.secondaryText)
    22. }
    23. }
    24. }
    25. }
    26. struct CoinImageView_Previews: PreviewProvider {
    27. static var previews: some View {
    28. CoinImageView(coin: dev.coin)
    29. .padding()
    30. .previewLayout(.sizeThatFits)
    31. }
    32. }
        3) 创建货币图片、标志、名称视图 CoinLogoView.swift
    1. import SwiftUI
    2. /// 货币的图片与名称
    3. struct CoinLogoView: View {
    4. let coin: CoinModel
    5. var body: some View {
    6. VStack {
    7. CoinImageView(coin: coin)
    8. .frame(width: 50, height: 50)
    9. Text(coin.symbol.uppercased())
    10. .font(.headline)
    11. .foregroundColor(Color.theme.accent)
    12. .lineLimit(1)
    13. .minimumScaleFactor(0.5)
    14. Text(coin.name)
    15. .font(.caption)
    16. .foregroundColor(Color.theme.secondaryText)
    17. .lineLimit(2)
    18. .minimumScaleFactor(0.5)
    19. .multilineTextAlignment(.center)
    20. }
    21. }
    22. }
    23. struct CoinLogoView_Previews: PreviewProvider {
    24. static var previews: some View {
    25. CoinLogoView(coin: dev.coin)
    26. .previewLayout(.sizeThatFits)
    27. }
    28. }

      6.2 圆形按钮视图组件

        1) 创建带阴影圆形按钮视图 CircleButtonView.swift
    1. import SwiftUI
    2. /// 带阴影圆形按钮视图
    3. struct CircleButtonView: View {
    4. let iconName: String
    5. var body: some View {
    6. Image(systemName: iconName)
    7. .font(.headline)
    8. .foregroundColor(Color.theme.accent)
    9. .frame(width: 50, height: 50)
    10. .background(
    11. Circle().foregroundColor(Color.theme.background)
    12. )
    13. .shadow(color: Color.theme.accent.opacity(0.25), radius: 10, x: 0, y: 0)
    14. .padding()
    15. }
    16. }
    17. struct CircleButtonView_Previews: PreviewProvider {
    18. static var previews: some View {
    19. Group {
    20. CircleButtonView(iconName: "info")
    21. // 预览区域 点预览布局,适合点的大小
    22. .previewLayout(.sizeThatFits)
    23. CircleButtonView(iconName: "plus")
    24. // 预览区域 点预览布局,适合点的大小 preferredColorScheme
    25. .previewLayout(.sizeThatFits)
    26. .preferredColorScheme(.dark)
    27. }
    28. }
    29. }
        2) 创建圆形按钮动画视图 CircleButtonAnimationView.swift
    1. import SwiftUI
    2. /// 圆形按钮动画视图
    3. struct CircleButtonAnimationView: View {
    4. // 是否动画
    5. @Binding var animate: Bool
    6. var body: some View {
    7. Circle()
    8. .stroke(lineWidth: 5.0)
    9. .scale(animate ? 1.0 : 0.0)
    10. .opacity(animate ? 0.0 : 1.0)
    11. .animation(animate ? Animation.easeOut(duration: 1.0) : .none)
    12. }
    13. }
    14. struct CircleButtonAnimationView_Previews: PreviewProvider {
    15. static var previews: some View {
    16. CircleButtonAnimationView(animate: .constant(false))
    17. .foregroundColor(.red)
    18. .frame(width: 100, height: 100)
    19. }
    20. }

      6.3 创建搜索框视图 SearchBarView.swift

    1. import SwiftUI
    2. /// 搜索框视图
    3. struct SearchBarView: View {
    4. @Binding var searchText: String
    5. var body: some View {
    6. HStack {
    7. Image(systemName: "magnifyingglass")
    8. .foregroundColor(
    9. searchText.isEmpty ?
    10. Color.theme.secondaryText : Color.theme.accent
    11. )
    12. TextField("Search by name or symbol...", text: $searchText)
    13. .foregroundColor(Color.theme.accent)
    14. // 键盘样式
    15. .keyboardType(.namePhonePad)
    16. // 禁用自动更正
    17. .autocorrectionDisabled(true)
    18. //.textContentType(.init(rawValue: ""))
    19. .overlay(
    20. Image(systemName: "xmark.circle.fill")
    21. .padding() // 加大图片到区域
    22. .offset(x: 10)
    23. .foregroundColor(Color.theme.accent)
    24. .opacity(searchText.isEmpty ? 0.0 : 1.0)
    25. .onTapGesture {
    26. // 结束编辑 隐藏键盘
    27. UIApplication.shared.endEditing()
    28. searchText = ""
    29. }
    30. ,alignment: .trailing
    31. )
    32. }
    33. .font(.headline)
    34. .padding()
    35. .background(
    36. RoundedRectangle(cornerRadius: 25)
    37. // 填充颜色
    38. .fill(Color.theme.background)
    39. // 阴影
    40. .shadow(
    41. color: Color.theme.accent.opacity(0.15),
    42. radius: 10, x: 0, y: 0)
    43. )
    44. .padding()
    45. }
    46. }
    47. struct SearchBarView_Previews: PreviewProvider {
    48. static var previews: some View {
    49. Group {
    50. SearchBarView(searchText: .constant(""))
    51. .previewLayout(.sizeThatFits)
    52. .preferredColorScheme(.light)
    53. SearchBarView(searchText: .constant(""))
    54. .previewLayout(.sizeThatFits)
    55. .preferredColorScheme(.dark)
    56. }
    57. }
    58. }

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

    1. import SwiftUI
    2. /// 统计数据视图
    3. struct StatisticView: View {
    4. let stat : StatisticModel
    5. var body: some View {
    6. VStack(alignment: .leading, spacing: 4) {
    7. Text(stat.title)
    8. .font(.caption)
    9. .foregroundColor(Color.theme.secondaryText)
    10. Text(stat.value)
    11. .font(.headline)
    12. .foregroundColor(Color.theme.accent)
    13. HStack (spacing: 4){
    14. Image(systemName: "triangle.fill")
    15. .font(.caption2)
    16. .rotationEffect(Angle(degrees: (stat.percentageChange ?? 0) >= 0 ? 0 : 180))
    17. Text(stat.percentageChange?.asPercentString() ?? "")
    18. .font(.caption)
    19. .bold()
    20. }
    21. .foregroundColor((stat.percentageChange ?? 0) >= 0 ? Color.theme.green : Color.theme.red)
    22. .opacity(stat.percentageChange == nil ? 0.0 : 1.0)
    23. }
    24. }
    25. }
    26. struct StatisticView_Previews: PreviewProvider {
    27. static var previews: some View {
    28. Group {
    29. StatisticView(stat: dev.stat1)
    30. .previewLayout(.sizeThatFits)
    31. //.preferredColorScheme(.dark)
    32. StatisticView(stat: dev.stat2)
    33. .previewLayout(.sizeThatFits)
    34. StatisticView(stat: dev.stat3)
    35. .previewLayout(.sizeThatFits)
    36. //.preferredColorScheme(.dark)
    37. }
    38. }
    39. }

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

    1. import SwiftUI
    2. /// 通用关闭按钮视图
    3. struct XMarkButton: View {
    4. // 环境变量: 呈现方式
    5. let presentationMode: Binding<PresentationMode>
    6. var body: some View {
    7. Button(action: {
    8. presentationMode.wrappedValue.dismiss()
    9. }, label: {
    10. HStack {
    11. Image(systemName: "xmark")
    12. .font(.headline)
    13. }
    14. })
    15. .foregroundColor(Color.theme.accent)
    16. }
    17. }
    18. struct XMarkButton_Previews: PreviewProvider {
    19. static var previews: some View {
    20. XMarkButton(presentationMode: dev.presentationMode)
    21. }
    22. }

    7. 主页 View/视图 层

      7.1 创建主页货币数据统计视图 HomeStatsView.swift

    1. import SwiftUI
    2. /// 主页货币数据统计视图
    3. struct HomeStatsView: View {
    4. /// 环境对象,主 ViewModel
    5. @EnvironmentObject private var viewModel: HomeViewModel
    6. /// 输出货币统计数据或者持有货币统计数据
    7. @Binding var showPortfolio: Bool
    8. var body: some View {
    9. HStack {
    10. ForEach(viewModel.statistics) { stat in
    11. StatisticView(stat: stat)
    12. .frame(width: UIScreen.main.bounds.width / 3)
    13. }
    14. }
    15. .frame(width: UIScreen.main.bounds.width, alignment: showPortfolio ? .trailing : .leading)
    16. }
    17. }
    18. struct HomeStatsView_Previews: PreviewProvider {
    19. static var previews: some View {
    20. // .constant(false)
    21. HomeStatsView(showPortfolio: .constant(false))
    22. .environmentObject(dev.homeViewModel)
    23. }
    24. }

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

    1. import SwiftUI
    2. /// 货币列表行视图
    3. struct CoinRowView: View {
    4. /// 硬币模型
    5. let coin: CoinModel;
    6. /// 控股列
    7. let showHoldingsColumn: Bool
    8. var body: some View {
    9. HStack(spacing: 0) {
    10. leftColumn
    11. Spacer()
    12. if showHoldingsColumn {
    13. centerColumn
    14. }
    15. rightColumn
    16. }
    17. .font(.subheadline)
    18. // 追加热区限制,使 Spacer 也可点击
    19. //.contentShape(Rectangle())
    20. // 添加背景,使得 Spacer 也可点击
    21. .background(Color.theme.background.opacity(0.001))
    22. }
    23. }
    24. // 扩展类
    25. extension CoinRowView{
    26. // 左边的View
    27. private var leftColumn: some View{
    28. HStack(spacing: 0) {
    29. // 显示排名,图片,名称
    30. Text("\(coin.rank)")
    31. .font(.caption)
    32. .foregroundColor(Color.theme.secondaryText)
    33. .frame(minWidth: 30)
    34. CoinImageView(coin: coin)
    35. .frame(width: 30, height: 30)
    36. Text(coin.symbol.uppercased())
    37. .font(.headline)
    38. .padding(.leading, 6)
    39. .foregroundColor(Color.theme.accent)
    40. }
    41. }
    42. // 中间的View
    43. private var centerColumn: some View{
    44. // 显示持有的股份
    45. VStack(alignment: .trailing) {
    46. // 显示持有的金额
    47. Text(coin.currentHoldingsValue.asCurrencyWith2Decimals())
    48. .bold()
    49. // 显示我们的持有量
    50. Text((coin.currentHoldings ?? 0).asNumberString())
    51. }
    52. .foregroundColor(Color.theme.accent)
    53. }
    54. // 右边的View
    55. private var rightColumn: some View{
    56. // 当前价格及上涨或者下跌24小时的百分比
    57. VStack(alignment: .trailing) {
    58. Text(coin.currentPrice.asCurrencyWith6Decimals())
    59. .bold()
    60. .foregroundColor(Color.theme.accent)
    61. Text(coin.priceChangePercentage24H?.asPercentString() ?? "")
    62. .foregroundColor((coin.priceChangePercentage24H ?? 0 ) >= 0 ? Color.theme.green : Color.theme.red)
    63. }
    64. .frame(width: UIScreen.main.bounds.width / 3.5, alignment: .trailing)
    65. }
    66. }
    67. struct CoinRowView_Previews: PreviewProvider {
    68. static var previews: some View {
    69. Group {
    70. CoinRowView(coin: dev.coin, showHoldingsColumn: true)
    71. .previewLayout(.sizeThatFits)
    72. CoinRowView(coin: dev.coin, showHoldingsColumn: true)
    73. .previewLayout(.sizeThatFits)
    74. .preferredColorScheme(.dark)
    75. }
    76. }
    77. }

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

    1. import SwiftUI
    2. /// 编辑持有交易货币投资组合视图
    3. struct PortfolioView: View {
    4. /// 环境变量,呈现方式:显示或者关闭
    5. @Environment(\.presentationMode) var presentationMode
    6. /// 环境变量中的主页 ViewModel
    7. @EnvironmentObject private var viewModel: HomeViewModel
    8. /// 是否选择其中一个模型
    9. @State private var selectedCoin: CoinModel? = nil
    10. /// 持有的数量
    11. @State private var quantityText: String = ""
    12. /// 是否点击保存按钮
    13. @State private var showCheckmark: Bool = false
    14. var body: some View {
    15. NavigationView {
    16. ScrollView {
    17. VStack(alignment: .leading, spacing: 0) {
    18. // 搜索框
    19. SearchBarView(searchText: $viewModel.searchText)
    20. // 带图片的水平货币列表
    21. coinLogoList
    22. //根据当前货币的金额,计算出持有的金额
    23. if selectedCoin != nil{
    24. portfolioInputSection
    25. }
    26. }
    27. }
    28. .background(
    29. Color.theme.background
    30. .ignoresSafeArea()
    31. )
    32. .navigationTitle("Edit portfolio")
    33. // navigationBarItems 已过时,推荐使用 toolbar,动态调整 View
    34. // .navigationBarItems(leading: XMarkButton())
    35. .toolbar {
    36. // 关闭按钮
    37. ToolbarItem(placement: .navigationBarLeading) {
    38. XMarkButton(presentationMode: presentationMode)
    39. }
    40. // 确认按钮
    41. ToolbarItem(placement: .navigationBarTrailing) {
    42. trailingNavBarButton
    43. }
    44. }
    45. // 观察页面上搜索的文字发生变化
    46. .onChange(of: viewModel.searchText) { value in
    47. // value == ""
    48. // 如果搜索框中的文字为空,移除选中列表中的货币
    49. if value.isEmpty {
    50. removeSelectedCoin()
    51. }
    52. }
    53. }
    54. }
    55. }
    56. // View 的扩展
    57. extension PortfolioView{
    58. /// 带图片的水平货币列表
    59. private var coinLogoList: some View {
    60. ScrollView(.horizontal, showsIndicators: false) {
    61. LazyHStack(spacing: 10) {
    62. ForEach(viewModel.searchText.isEmpty ? viewModel.portfolioCoins : viewModel.allCoins) { coin in
    63. CoinLogoView(coin: coin)
    64. .frame(width: 75)
    65. .padding(4)
    66. .onTapGesture {
    67. withAnimation(.easeIn) {
    68. updateSelectedCoin(coin: coin)
    69. }
    70. }
    71. .background(
    72. RoundedRectangle(cornerRadius: 10)
    73. .stroke(selectedCoin?.id == coin.id ?
    74. Color.theme.green : Color.clear
    75. , lineWidth: 1)
    76. )
    77. }
    78. }
    79. .frame(height: 120)
    80. .padding(.leading)
    81. }
    82. }
    83. /// 更新点击的货币信息
    84. private func updateSelectedCoin(coin: CoinModel){
    85. selectedCoin = coin
    86. if let portfolioCoin = viewModel.portfolioCoins.first(where: {$0.id == coin.id}),
    87. let amount = portfolioCoin.currentHoldings{
    88. quantityText = "\(amount)"
    89. }else{
    90. quantityText = ""
    91. }
    92. }
    93. /// 获取当前持有货币金额
    94. private func getCurrentValue() -> Double {
    95. // 获取数量
    96. if let quantity = Double(quantityText){
    97. return quantity * (selectedCoin?.currentPrice ?? 0)
    98. }
    99. return 0
    100. }
    101. /// 根据当前货币的金额,计算出持有的金额
    102. private var portfolioInputSection: some View {
    103. VStack(spacing: 20) {
    104. // 当前货币的价格
    105. HStack {
    106. Text("Current price of \(selectedCoin?.symbol.uppercased() ?? ""):")
    107. Spacer()
    108. Text(selectedCoin?.currentPrice.asCurrencyWith6Decimals() ?? "")
    109. }
    110. Divider()
    111. // 持有的货币数量
    112. HStack {
    113. Text("Amount holding:")
    114. Spacer()
    115. TextField("Ex: 1.4", text: $quantityText)
    116. // 右对齐
    117. .multilineTextAlignment(.trailing)
    118. // 设置键盘类型,只能为数字
    119. .keyboardType(.decimalPad)
    120. }
    121. Divider()
    122. HStack {
    123. Text("Current value:")
    124. Spacer()
    125. Text(getCurrentValue().asCurrencyWith2Decimals())
    126. }
    127. }
    128. .animation(.none)
    129. .padding()
    130. .font(.headline)
    131. }
    132. /// 导航栏右侧的保存按钮
    133. private var trailingNavBarButton: some View{
    134. HStack(spacing: 10) {
    135. Image(systemName: "checkmark")
    136. .opacity(showCheckmark ? 1.0 : 0.0)
    137. //.foregroundColor(Color.theme.accent)
    138. Button {
    139. saveButtonPressed()
    140. } label: {
    141. Text("Save".uppercased())
    142. }
    143. // 选中当前的货币并且持有的货币数量与输入的数量不相等时,显示保存按钮
    144. .opacity((selectedCoin != nil && selectedCoin?.currentHoldings != Double(quantityText)) ? 1.0 : 0.0)
    145. }
    146. .font(.headline)
    147. }
    148. /// 按下保存按钮
    149. private func saveButtonPressed(){
    150. // 判断是否有选中按钮
    151. guard
    152. let coin = selectedCoin,
    153. let amount = Double(quantityText)
    154. else { return }
    155. // 保存/更新到持有投资组合货币
    156. viewModel.updatePortfolio(coin: coin, amount: amount)
    157. // 显示检查标记
    158. withAnimation(.easeIn) {
    159. showCheckmark = true
    160. removeSelectedCoin()
    161. }
    162. // 隐藏键盘
    163. UIApplication.shared.endEditing()
    164. // 隐藏检查标记
    165. DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
    166. withAnimation(.easeOut){
    167. showCheckmark = false
    168. }
    169. }
    170. }
    171. // 移除选中列表中的货币
    172. private func removeSelectedCoin(){
    173. selectedCoin = nil
    174. // 清空搜索框
    175. viewModel.searchText = ""
    176. }
    177. }
    178. struct PortfolioView_Previews: PreviewProvider {
    179. static var previews: some View {
    180. PortfolioView()
    181. .environmentObject(dev.homeViewModel)
    182. }
    183. }

      7.4 创建主页视图 HomeView.swift

    1. import SwiftUI
    2. // .constant("") State(wrappedValue:)
    3. // 加密货币
    4. struct HomeView: View {
    5. @EnvironmentObject private var viewModel:HomeViewModel
    6. /// 是否显示动画
    7. @State private var showPortfolio: Bool = false
    8. /// 是否显示编辑持有货币 View
    9. @State private var showPortfolioView: Bool = false
    10. /// 是否显示设置View
    11. @State private var showSettingView: Bool = false
    12. /// 选中的交易货币
    13. @State private var selectedCoin: CoinModel? = nil
    14. /// 是否显示交易货币详情页
    15. @State private var showDetailView: Bool = false
    16. var body: some View {
    17. ZStack {
    18. // 背景布局 background layer
    19. Color.theme.background
    20. .ignoresSafeArea()
    21. // 新的工作表单,持有货币组合 View
    22. .sheet(isPresented: $showPortfolioView) {
    23. PortfolioView()
    24. // 环境变量对象添加 ViewModel
    25. .environmentObject(viewModel)
    26. }
    27. // 内容布局
    28. VStack {
    29. // 顶部导航栏
    30. homeHeader
    31. // 统计栏
    32. HomeStatsView(showPortfolio: $showPortfolio)
    33. // 搜索框
    34. SearchBarView(searchText: $viewModel.searchText)
    35. // 列表标题栏
    36. columnTitles
    37. // 货币列表数据
    38. coinSectionUsingTransitions
    39. //coinSectionUsingOffsets
    40. Spacer(minLength: 0)
    41. }
    42. // 设置页面
    43. .sheet(isPresented: $showSettingView) {
    44. SettingsView()
    45. }
    46. }
    47. .background(
    48. NavigationLink(
    49. destination: DetailLoadingView(coin: $selectedCoin),
    50. isActive: $showDetailView,
    51. label: { EmptyView() })
    52. )
    53. }
    54. }
    55. struct HomeView_Previews: PreviewProvider {
    56. static var previews: some View {
    57. NavigationView {
    58. HomeView()
    59. //.navigationBarHidden(true)
    60. }
    61. .environmentObject(dev.homeViewModel)
    62. }
    63. }
    64. // 扩展 HomeView
    65. extension HomeView{
    66. // 主页顶部 View
    67. private var homeHeader: some View{
    68. HStack {
    69. CircleButtonView(iconName: showPortfolio ? "plus" : "info")
    70. .animation(.none)
    71. .onTapGesture {
    72. if showPortfolio {
    73. showPortfolioView.toggle()
    74. } else {
    75. showSettingView.toggle()
    76. }
    77. }
    78. .background(CircleButtonAnimationView(animate: $showPortfolio))
    79. Spacer()
    80. Text(showPortfolio ? "Portfolio" : "Live Prices")
    81. .font(.headline)
    82. .fontWeight(.heavy)
    83. .foregroundColor(Color.theme.accent)
    84. .animation(.none)
    85. Spacer()
    86. CircleButtonView(iconName: "chevron.right")
    87. .rotationEffect(Angle(degrees: showPortfolio ? 180 : 0))
    88. .onTapGesture {
    89. // 添加动画
    90. withAnimation(.spring()){
    91. showPortfolio.toggle()
    92. }
    93. }
    94. }
    95. .padding(.horizontal)
    96. }
    97. /// 交易货币数据列表
    98. private var coinSectionUsingTransitions: some View{
    99. ZStack(alignment: .top) {
    100. if !showPortfolio{
    101. if !viewModel.allCoins.isEmpty {
    102. allCoinsList
    103. // 将 view 从右侧推到左侧
    104. .transition(.move(edge: .leading))
    105. }
    106. }
    107. // 持有的货币列表
    108. if showPortfolio{
    109. ZStack(alignment: .top) {
    110. if viewModel.portfolioCoins.isEmpty && viewModel.searchText.isEmpty{
    111. // 当没有持有交易货币时,给出提示语
    112. portfolioEmptyText
    113. } else{
    114. // 持有交易货币投资组合列表
    115. if !viewModel.portfolioCoins.isEmpty {
    116. portfolioCoinsList
    117. }
    118. }
    119. }
    120. .transition(.move(edge: .trailing))
    121. }
    122. }
    123. }
    124. /// 交易货币数据列表
    125. private var coinSectionUsingOffsets: some View{
    126. ZStack(alignment: .top) {
    127. if !showPortfolio{
    128. allCoinsList
    129. // 将 view 从右侧推到左侧
    130. .offset(x: showPortfolio ? -UIScreen.main.bounds.width : 0)
    131. }
    132. // 持有的货币列表
    133. if showPortfolio{
    134. ZStack(alignment: .top) {
    135. if viewModel.portfolioCoins.isEmpty && viewModel.searchText.isEmpty{
    136. // 当没有持有交易货币时,给出提示语
    137. portfolioEmptyText
    138. } else{
    139. // 持有交易货币投资组合列表
    140. portfolioCoinsList
    141. }
    142. }
    143. .offset(x: showPortfolio ? 0 : UIScreen.main.bounds.width)
    144. }
    145. }
    146. }
    147. /// 交易货币列表
    148. private var allCoinsList: some View{
    149. List {
    150. ForEach(viewModel.allCoins) { coin in
    151. CoinRowView(coin: coin, showHoldingsColumn: false)
    152. .listRowInsets(.init(top: 10, leading: 0, bottom: 10, trailing: 10))
    153. .onTapGesture {
    154. segue(coin: coin)
    155. }
    156. .listRowBackground(Color.theme.background)
    157. }
    158. }
    159. //.modifier(ListBackgroundModifier())
    160. //.background(Color.theme.background.ignoresSafeArea())
    161. .listStyle(.plain)
    162. }
    163. /// 持有交易货币投资组合列表
    164. private var portfolioCoinsList: some View{
    165. List {
    166. ForEach(viewModel.portfolioCoins) { coin in
    167. CoinRowView(coin: coin, showHoldingsColumn: true)
    168. .listRowInsets(.init(top: 10, leading: 0, bottom: 10, trailing: 10))
    169. .onTapGesture {
    170. segue(coin: coin)
    171. }
    172. .listRowBackground(Color.theme.background)
    173. }
    174. }
    175. .listStyle(.plain)
    176. }
    177. /// 当没有持有交易货币时,给出提示语
    178. private var portfolioEmptyText: some View{
    179. Text("You haven't added any coins to your portfolio yet. Click the + button to get started! 🧐")
    180. .font(.callout)
    181. .foregroundColor(Color.theme.accent)
    182. .fontWeight(.medium)
    183. .multilineTextAlignment(.center)
    184. .padding(50)
    185. }
    186. /// 跳转到交易货币详情页
    187. private func segue(coin: CoinModel){
    188. selectedCoin = coin
    189. showDetailView.toggle()
    190. }
    191. /// 列表的标题
    192. private var columnTitles: some View{
    193. HStack {
    194. // 硬币
    195. HStack(spacing: 4) {
    196. Text("Coin")
    197. Image(systemName: "chevron.down")
    198. .opacity((viewModel.sortOption == .rank || viewModel.sortOption == .rankReversed) ? 1.0 : 0.0)
    199. .rotationEffect(Angle(degrees: viewModel.sortOption == .rank ? 0 : 180))
    200. }
    201. .onTapGesture {
    202. // 设置排序
    203. withAnimation(.default) {
    204. viewModel.sortOption = (viewModel.sortOption == .rank ? .rankReversed : .rank)
    205. }
    206. }
    207. Spacer()
    208. if showPortfolio{
    209. // 持有交易货币的控股
    210. HStack(spacing: 4) {
    211. Text("Holdings")
    212. Image(systemName: "chevron.down")
    213. .opacity((viewModel.sortOption == .holdings || viewModel.sortOption == .holdingsReversed) ? 1.0 : 0.0)
    214. .rotationEffect(Angle(degrees: viewModel.sortOption == .holdings ? 0 : 180))
    215. }
    216. .onTapGesture {
    217. // 设置排序
    218. withAnimation(.default) {
    219. viewModel.sortOption = (viewModel.sortOption == .holdings ? .holdingsReversed : .holdings)
    220. }
    221. }
    222. }
    223. HStack(spacing: 4) {
    224. // 价格
    225. Text("Price")
    226. .frame(width: UIScreen.main.bounds.width / 3.5, alignment: .trailing)
    227. Image(systemName: "chevron.down")
    228. .opacity((viewModel.sortOption == .price || viewModel.sortOption == .priceReversed) ? 1.0 : 0.0)
    229. .rotationEffect(Angle(degrees: viewModel.sortOption == .price ? 0 : 180))
    230. }
    231. .onTapGesture {
    232. // 设置排序
    233. withAnimation(.default) {
    234. viewModel.sortOption = (viewModel.sortOption == .price ? .priceReversed : .price)
    235. }
    236. }
    237. // 刷新
    238. Button {
    239. withAnimation(.linear(duration: 2.0)) {
    240. viewModel.reloadData()
    241. }
    242. } label: {
    243. Image(systemName: "goforward")
    244. }
    245. // 添加旋转动画
    246. .rotationEffect(Angle(degrees: viewModel.isLoading ? 360 : 0), anchor: .center)
    247. }
    248. .font(.caption)
    249. .foregroundColor(Color.theme.secondaryText)
    250. .padding(.horizontal)
    251. }
    252. }

    8. 效果图:

  • 相关阅读:
    cpu飙高问题,案例分析(一)
    VB.net WebBrowser网页元素抓取分析方法
    MM32F0140 UART1空闲中断接收
    SCI写作指南
    深入理解Kafka分区副本机制
    使用svnsync sync方法同步
    DDTT Sulfurizing Reagent,DDTT硫化剂
    Unity性能优化一本通
    哈工大2022机器学习实验一:曲线拟合
    无敌,全面对标字节跳动2-2:算法与数据结构突击手册(leetcode)
  • 原文地址:https://blog.csdn.net/u011193452/article/details/133751436