1. 创建数据模型
1.1 创建货币模型 CoinModel.swift
import Foundation
// GoinGecko API info
/*
URL:
https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=1&sparkline=true&price_change_percentage=24h&locale=en&precision=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
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
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
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{
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){
switch completion{
case .finished:
break
case .failure(let error):
print(error.localizedDescription)
break
}
}
}
2.2 创建本地文件管理器 LocalFileManager.swift
import Foundation
import SwiftUI
/// 本地文件管理器
class LocalFileManager{
// 单例模式
static let instance = LocalFileManager()
// 保证应用程序中只有一个实例并且只能在内部实例化
private init() {}
// 保存图片
func saveImage(image: UIImage, imageName: String, folderName: String) {
// 创建文件夹路径
createFolderIfNeeded(folderName: folderName)
// 获取图片的路径
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
import Foundation
import SwiftUI
/// 触觉管理器
class HapticManager{
/// 通知反馈生成器器
static private let generator = UINotificationFeedbackGenerator()
/// 通知: 反馈类型
static func notification(type: UINotificationFeedbackGenerator.FeedbackType){
generator.notificationOccurred(type)
}
}
3. 创建扩展类
3.1 创建颜色扩展类 Color.swift
import Foundation
import SwiftUI
/// 扩展类 颜色
extension Color{
static let theme = ColorTheme()
static let launch = LaunchTheme()
}
/// 颜色样式
struct ColorTheme{
let accent = Color("AccentColor")
let background = Color("BackgroundColor")
let green = Color("GreenColor")
let red = Color("RedColor")
let secondaryText = Color("SecondaryTextColor")
}
/// 颜色样式2
struct ColorTheme2{
let accent = Color(#colorLiteral(red: 0, green: 0.9914394021, blue: 1, alpha: 1))
let background = Color(#colorLiteral(red: 0.09019608051, green: 0, blue: 0.3019607961, alpha: 1))
let green = Color(#colorLiteral(red: 0, green: 0.5603182912, blue: 0, alpha: 1))
let red = Color(#colorLiteral(red: 0.5807225108, green: 0.066734083, blue: 0, alpha: 1))
let secondaryText = Color(#colorLiteral(red: 0.7540688515, green: 0.7540867925, blue: 0.7540771365, alpha: 1))
}
/// 启动样式
struct LaunchTheme {
let accent = Color("LaunchAccentColor")
let background = Color("LaunchBackgroundColor")
}
3.2 创建提供预览视图扩展类 PreviewProvider.swift
import Foundation
import SwiftUI
/// 扩展类 提供预览
extension PreviewProvider{
// 开发者预览数据
static var dev: DeveloperPreview{
return DeveloperPreview.instance
}
}
// 开发者预览版
class DeveloperPreview{
// 单例模式
static let instance = DeveloperPreview()
private init() {}
// 环境变量,呈现的模式:显示或者关闭
@Environment(\.presentationMode) var 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
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
import Foundation
import SwiftUI
extension UIApplication{
/// 结束编辑,隐藏键盘
func endEditing(){
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
3.5 创建日期扩展类 Date.swift
import Foundation
/// 扩展类 日期
extension Date {
// "2021-11-10T14:24:11.849Z"
init(coinGeckoString: String) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
// 指定日期格式转换
let date = formatter.date(from: coinGeckoString) ?? Date()
self.init(timeInterval: 0, since: date)
}
// 输出短格式
private var shortFormatter: DateFormatter{
let formatter = DateFormatter()
formatter.dateStyle = .short
return formatter
}
// 转换为字符串短类型
func asShortDateString() -> String{
return shortFormatter.string(from: self)
}
}
3.6 创建字符串扩展类 String.swift
import Foundation
/// 扩展类 字符串
extension String{
/// 移除 HTML 内容,查找到 HTML 标记,用 "" 替代
var removingHTMLOccurances: String{
return self.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil)
}
}
4. 创建数据服务类
4.1 创建货币数据服务类 CoinDataService.swift
import Foundation
import Combine
/// 货币数据服务
class CoinDataService{
// 硬币模型数组 Published: 可以拥有订阅者
@Published var allCoins: [CoinModel] = []
// 随时取消操作
var coinSubscription: AnyCancellable?
init() {
getCoins()
}
// 获取全部硬币
func getCoins(){
guard let url = URL(string: "https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=250&page=1&sparkline=true&price_change_percentage=24h&locale=en&precision=2")
else { return }
coinSubscription = NetworkingManager.downLoad(url: url)
.decode(type: [CoinModel].self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: NetworkingManager.handleCompletion,
receiveValue: { [weak self] returnCoins in
// 解除强引用 (注意)
self?.allCoins = returnCoins
// 取消订阅者
self?.coinSubscription?.cancel()
})
}
}
4.2 创建货币图片下载缓存服务类 CoinImageService.swift
import Foundation
import SwiftUI
import Combine
/// 货币图片下载缓存服务
class CoinImageService{
@Published var image: UIImage? = nil
// 随时取消操作
private var imageSubscription: AnyCancellable?
private let coin: 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
import Foundation
import Combine
/// 市场数据服务
class MarketDataService{
// 市场数据模型数组 Published: 可以拥有订阅者
@Published var marketData: MarketDataModel? = nil
// 随时取消操作
var marketDataSubscription: AnyCancellable?
init() {
getData()
}
// 获取全部硬币
func getData(){
guard let url = URL(string: "https://api.coingecko.com/api/v3/global") else { return }
marketDataSubscription = NetworkingManager.downLoad(url: url)
.decode(type: GlobalData.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: NetworkingManager.handleCompletion,
receiveValue: { [weak self] returnGlobalData in
// 解除强引用 (注意)
self?.marketData = returnGlobalData.data
// 取消订阅者
self?.marketDataSubscription?.cancel()
})
}
}
4.4 创建持有交易货币投资组合数据存储服务(核心数据存储) PortfolioDataService.swift
import Foundation
import CoreData
/// 持有交易货币投资组合数据存储服务(核心数据存储)
class PortfolioDataService{
// 数据容器
private let container: NSPersistentContainer
// 容器名称
private let containerName: String = "PortfolioContainer"
// 实体名称
private let entityName: String = "PortfolioEntity"
// 投资组合实体集合
@Published var savedEntities: [PortfolioEntity] = []
init() {
// 获取容器文件
container = NSPersistentContainer(name: containerName)
// 加载持久存储
container.loadPersistentStores { _, error 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(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
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()
/// 排序选项
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
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()
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
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
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
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
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
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
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
import SwiftUI
/// 通用关闭按钮视图
struct XMarkButton: View {
// 环境变量: 呈现方式
let presentationMode: Binding
var body: some View {
Button(action: {
presentationMode.wrappedValue.dismiss()
}, label: {
HStack {
Image(systemName: "xmark")
.font(.headline)
}
})
.foregroundColor(Color.theme.accent)
}
}
struct XMarkButton_Previews: PreviewProvider {
static var previews: some View {
XMarkButton(presentationMode: dev.presentationMode)
}
}
7. 主页 View/视图 层
7.1 创建主页货币数据统计视图 HomeStatsView.swift
import SwiftUI
/// 主页货币数据统计视图
struct HomeStatsView: View {
/// 环境对象,主 ViewModel
@EnvironmentObject private var viewModel: HomeViewModel
/// 输出货币统计数据或者持有货币统计数据
@Binding var showPortfolio: 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
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
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
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. 效果图: