DetailView/货币详情页 的实现

1. 创建货币详情数据模型类 CoinDetailModel.swift

Swift 复制代码
import Foundation

// JSON Data
/*
 URL:
 https://api.coingecko.com/api/v3/coins/bitcoin?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false&sparkline=false
 
 Response:
 {
   "id": "bitcoin",
   "symbol": "btc",
   "name": "Bitcoin",
   "asset_platform_id": null,
   "platforms": {
     "": ""
   },
   "detail_platforms": {
     "": {
       "decimal_place": null,
       "contract_address": ""
     }
   },
   "block_time_in_minutes": 10,
   "hashing_algorithm": "SHA-256",
   "categories": [
     "Cryptocurrency",
     "Layer 1 (L1)"
   ],
   "public_notice": null,
   "additional_notices": [],
   "description": {
     "en": "Bitcoin is the first successful internet money based on peer-to-peer technology; whereby no central bank or authority is involved in the transaction and production of the Bitcoin currency. It was created by an anonymous individual/group under the name, Satoshi Nakamoto. The source code is available publicly as an open source project, anybody can look at it and be part of the developmental process.\r\n\r\nBitcoin is changing the way we see money as we speak. The idea was to produce a means of exchange, independent of any central authority, that could be transferred electronically in a secure, verifiable and immutable way. It is a decentralized peer-to-peer internet currency making mobile payment easy, very low transaction fees, protects your identity, and it works anywhere all the time with no central authority and banks.\r\n\r\nBitcoin is designed to have only 21 million BTC ever created, thus making it a deflationary currency. Bitcoin uses the <a href=\"https://www.coingecko.com/en?hashing_algorithm=SHA-256\">SHA-256</a> hashing algorithm with an average transaction confirmation time of 10 minutes. Miners today are mining Bitcoin using ASIC chip dedicated to only mining Bitcoin, and the hash rate has shot up to peta hashes.\r\n\r\nBeing the first successful online cryptography currency, Bitcoin has inspired other alternative currencies such as <a href=\"https://www.coingecko.com/en/coins/litecoin\">Litecoin</a>, <a href=\"https://www.coingecko.com/en/coins/peercoin\">Peercoin</a>, <a href=\"https://www.coingecko.com/en/coins/primecoin\">Primecoin</a>, and so on.\r\n\r\nThe cryptocurrency then took off with the innovation of the turing-complete smart contract by <a href=\"https://www.coingecko.com/en/coins/ethereum\">Ethereum</a> which led to the development of other amazing projects such as <a href=\"https://www.coingecko.com/en/coins/eos\">EOS</a>, <a href=\"https://www.coingecko.com/en/coins/tron\">Tron</a>, and even crypto-collectibles such as <a href=\"https://www.coingecko.com/buzz/ethereum-still-king-dapps-cryptokitties-need-1-billion-on-eos\">CryptoKitties</a>."
   },
   "links": {
     "homepage": [
       "http://www.bitcoin.org",
       "",
       ""
     ],
     "blockchain_site": [
       "https://blockchair.com/bitcoin/",
       "https://btc.com/",
       "https://btc.tokenview.io/",
       "https://www.oklink.com/btc",
       "https://3xpl.com/bitcoin",
       "",
       "",
       "",
       "",
       ""
     ],
     "official_forum_url": [
       "https://bitcointalk.org/",
       "",
       ""
     ],
     "chat_url": [
       "",
       "",
       ""
     ],
     "announcement_url": [
       "",
       ""
     ],
     "twitter_screen_name": "bitcoin",
     "facebook_username": "bitcoins",
     "bitcointalk_thread_identifier": null,
     "telegram_channel_identifier": "",
     "subreddit_url": "https://www.reddit.com/r/Bitcoin/",
     "repos_url": {
       "github": [
         "https://github.com/bitcoin/bitcoin",
         "https://github.com/bitcoin/bips"
       ],
       "bitbucket": []
     }
   },
   "image": {
     "thumb": "https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579",
     "small": "https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579",
     "large": "https://assets.coingecko.com/coins/images/1/large/bitcoin.png?1547033579"
   },
   "country_origin": "",
   "genesis_date": "2009-01-03",
   "sentiment_votes_up_percentage": 73.21,
   "sentiment_votes_down_percentage": 26.79,
   "watchlist_portfolio_users": 1326950,
   "market_cap_rank": 1,
   "coingecko_rank": 1,
   "coingecko_score": 83.151,
   "developer_score": 99.241,
   "community_score": 83.341,
   "liquidity_score": 100.011,
   "public_interest_score": 0.073,
   "public_interest_stats": {
     "alexa_rank": 9440,
     "bing_matches": null
   },
   "status_updates": [],
   "last_updated": "2023-08-11T08:43:13.856Z"
 }
 */


/// 交易货币详情模型
struct CoinDetailModel: Codable {
    let id, symbol, name: String?
    let blockTimeInMinutes: Int?
    let hashingAlgorithm: String?
    let description: Description?
    let links: Links?
    
    enum CodingKeys: String, CodingKey {
        case id, symbol, name
        case blockTimeInMinutes = "block_time_in_minutes"
        case hashingAlgorithm = "hashing_algorithm"
        case description, links
    }
    
    /// 去掉 HTML 链接的描述
    var readableDescription: String? {
        return description?.en?.removingHTMLOccurances
    }
}

struct Description: Codable {
    let en: String?
}

struct Links: Codable {
    let homepage: [String]?
    let subredditURL: String?
    
    enum CodingKeys: String, CodingKey {
        case homepage
        case subredditURL = "subreddit_url"
    }
}

2. 创建货币详情数据服务类 CoinDetailDataService.swift

Swift 复制代码
import Foundation
import Combine

/// 交易货币详情服务
class CoinDetailDataService{
    // 交易货币详情模型数组 Published: 可以拥有订阅者
    @Published var coinDetails: CoinDetailModel? = nil
    // 随时取消操作
    var coinDetailSubscription: AnyCancellable?
    // 传入的货币模型
    let coin: CoinModel
    
    init(coin: CoinModel) {
        self.coin = coin
        getCoinDetails()
    }
    
    /// 获取交易硬币详情
    func getCoinDetails(){
        guard let url = URL(string: "https://api.coingecko.com/api/v3/coins/\(coin.id)?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false&sparkline=false")
        else { return }
        
        coinDetailSubscription = NetworkingManager.downLoad(url: url)
            .decode(type: CoinDetailModel.self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .sink(receiveCompletion: NetworkingManager.handleCompletion,
                  receiveValue: { [weak self] returnCoinDetails in
                // 解除强引用 (注意)
                self?.coinDetails = returnCoinDetails
                // 取消订阅者
                self?.coinDetailSubscription?.cancel()
            })
    }
}

3. 创建货币详情 ViewModel 类,DetailViewModel.swift

Swift 复制代码
import Foundation
import Combine

/// 交易货币详情 ViewModel
class DetailViewModel: ObservableObject {
    /// 概述统计模型数组
    @Published var overviewStatistics: [StatisticModel] = []
    /// 附加统计数据数组
    @Published var additionalStatistics: [StatisticModel] = []
    /// 货币描述
    @Published var description: String? = nil
    /// 货币官网网站
    @Published var websiteURL: String? = nil
    /// 货币社区网站
    @Published var redditURL: String? = nil
    
    /// 交易货币模型
    @Published var coin: CoinModel
    /// 交易货币详情请求服务
    private let coinDetailService: CoinDetailDataService
    /// 随时取消订阅
    private var cancellables = Set<AnyCancellable>()
    
    init(coin: CoinModel) {
        self.coin = coin
        self.coinDetailService = CoinDetailDataService(coin: coin)
        self.addSubscribers()
    }
    
    /// 添加订阅者
    private func addSubscribers(){
        // 订阅货币详情数据
        coinDetailService.$coinDetails
            .combineLatest($coin)
             // 数据的转换
            .map(mapDataToStatistics)
            .sink {[weak self] returnedArrays in
                self?.overviewStatistics = returnedArrays.overview
                self?.additionalStatistics = returnedArrays.additional
            }
            .store(in: &cancellables)
        
        // 订阅货币详情数据
        coinDetailService.$coinDetails
            .sink {[weak self] returnedCoinDetails in
                self?.description = returnedCoinDetails?.readableDescription
                self?.websiteURL = returnedCoinDetails?.links?.homepage?.first
                self?.redditURL = returnedCoinDetails?.links?.subredditURL
            }
            .store(in: &cancellables)
    }
    
    /// 数据转换为统计信息数据
    private func mapDataToStatistics(coinDetailModel: CoinDetailModel?, coinModel: CoinModel) -> (overview: [StatisticModel], additional: [StatisticModel]){
        // 概述信息
        // 当前货币概述信息
        let overviewArray = createOvervierArray(coinModel: coinModel)
        // 附加信息
        // 当前货币附加信息
        let additionalArray = createAdditionalArray(coinModel: coinModel, coinDetailModel: coinDetailModel)
        // 返回数组
        return (overviewArray, additionalArray)
    }
    
    /// 创建概述信息数组
    private func createOvervierArray(coinModel: CoinModel) -> [StatisticModel]{
        // 当前交易货币价格
        let price = coinModel.currentPrice.asCurrencyWith6Decimals()
        // 当前交易货币价格 24 小时的变化百分比
        let pricePercentChange = coinModel.priceChangePercentage24H
        // 当前交易货币价格 统计信息
        let priceStat = StatisticModel(title: "Current Price", value: price, percentageChange: pricePercentChange)
        
        // 市值 价格
        let marketCap = "$" + (coinModel.marketCap?.formattedWithAbbreviations() ?? "")
        // 市值 24 小时变化百分比
        let marketCapPercentChange = coinModel.marketCapChangePercentage24H
        // 市值 统计信息
        let marketCapStat = StatisticModel(title: "Market Capitalization", value: marketCap, percentageChange: marketCapPercentChange)
        
        // 当前交易货币的排名
        let rank = "\(coinModel.rank)"
        // 当前货币排名 统计信息
        let rankStat = StatisticModel(title: "Rank", value: rank)
        
        // 交易总量
        let volume = coinModel.totalVolume?.formattedWithAbbreviations() ?? ""
        // 交易 统计信息
        let volumeStat = StatisticModel(title: "Volume", value: volume)
        
        // 当前货币概述信息
        return [priceStat, marketCapStat, rankStat, volumeStat]
    }
    
    /// 创建附加信息数组
    private func createAdditionalArray(coinModel: CoinModel, coinDetailModel: CoinDetailModel?) -> [StatisticModel]{
        // 24 小时内最高点
        let high = coinModel.high24H?.asCurrencyWith6Decimals() ?? "n/a"
        // 最高点 统计信息
        let highStat = StatisticModel(title: "24h High", value: high)
        
        // 24 小时内最低点
        let  low = coinModel.low24H?.asCurrencyWith6Decimals() ?? "n/a"
        // 最低点 统计信息
        let lowStat = StatisticModel(title: "24h Low", value: low)
        
        // 24 小时内价格变化
        let priceChange = coinModel.priceChange24H?.asCurrencyWith6Decimals() ?? "n/a"
        // 当前交易货币 24 小时的价格变化百分比
        let pricePercentChange2 = coinModel.priceChangePercentage24H
        // 24 小时内价格变化 统计信息
        let priceChangeStat = StatisticModel(title: "24h Price Change", value: priceChange, percentageChange: pricePercentChange2)
        
        // 24 小时内市值变化值
        let marketCapChange = "$" + (coinModel.marketCapChange24H?.formattedWithAbbreviations() ?? "")
        // 市值 24 小时变化百分比
        let marketCapPercentChange2 = coinModel.marketCapChangePercentage24H
        // 24 小时内市值变换 统计信息
        let marketCapChangeStat = StatisticModel(title: "24h Market Cap Change", value: marketCapChange, percentageChange: marketCapPercentChange2)
        
        // 区块时间 (分钟为单位)
        let blockTime = coinDetailModel?.blockTimeInMinutes ?? 0
        let blockTimeString = blockTime == 0 ? "n/a" : "\(blockTime)"
        // 统计信息
        let blockTimeStat = StatisticModel(title: "Block Time", value: blockTimeString)
        
        // 哈希/散列 算法
        let hashing = coinDetailModel?.hashingAlgorithm ?? "n/a"
        let hashingStat = StatisticModel(title: "Hashing Algorithm", value: hashing)
        
        // 当前货币附加信息
        return [highStat, lowStat, priceChangeStat, marketCapChangeStat, blockTimeStat, hashingStat]
    }
}

4. 货币详情 View/视图 层

4.1 创建折线视图 ChartView.swift

Swift 复制代码
import SwiftUI

/// 折线视图
struct ChartView: View {
    // 7 天价格中的交易货币数据
    private let data: [Double]
    // Y 最大值
    private let maxY: Double
    // Y 最小值
    private let minY: Double
    // 线条颜色
    private let lineColor: Color
    // 开始日期
    private let startingDate: Date
    // 结束日期
    private let endingDate: Date
    // 绘制折线进度的百分比
    @State private var percentage: CGFloat = 0
    
    init(coin: CoinModel) {
        data = coin.sparklineIn7D?.price ?? []
        maxY = data.max() ?? 0
        minY = data.min() ?? 0
        
        // 最后一个价格减去第一个价格,为当前的价格变化量
        let priceChange = (data.last ?? 0) - (data.first ?? 0)
        // 线条颜色
        lineColor = priceChange > 0 ? Color.theme.green : Color.theme.red
        
        // 转换开始结束时间格式
        endingDate = Date(coinGeckoString: coin.lastUpdated ?? "")
        // 没有返回开始时间,根据他是结束日期的前七天,所以定义为结束时间之前间隔为 -7 天
        startingDate = endingDate.addingTimeInterval(-7 * 24 * 60 * 60)
    }
    
    // 计算 X 点的位置:
    // 300 : Viw 的宽
    // 100 : 数据个数
    // 3   : 得到的增量 300 / 100
    // 1 * 3 = 3  : x 位置 的计算
    // 2 * 3 = 6
    // 3 * 3 = 9
    // 100 * 3 = 300
    
    // 计算 Y 点的位置
    // 60,000 -> 最大值
    // 50,000 -> 最小值
    // 60,000 - 50,000 = 10,000 -> yAxis / Y轴
    // 52,000 - data point
    // 52,000 - 50,000 = 2,000 / 10,000 = 20%
    
    var body: some View {
        VStack {
            // 折线视图
            chartView
                .frame(height: 200)
                .background(chartBackground)
                .overlay(chartYAxis.padding(.horizontal, 4), alignment: .leading)
            // X 轴日期文字
            chartDateLabels
                .padding(.horizontal, 4)
        }
        .font(.caption)
        .foregroundColor(Color.theme.secondaryText)
        .onAppear {
            // 线程
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
                // 线程具有动画效果
                withAnimation(.linear(duration: 2.0)) {
                    percentage = 1.0
                }
            }
        }
    }
}

struct ChartView_Previews: PreviewProvider {
    static var previews: some View {
        ChartView(coin: dev.coin)
    }
}

extension ChartView{
    /// 折线视图
    private var chartView: some View{
        // GeometryReader: 根据试图大小自动布局页面
        GeometryReader{ geometry in
            Path { path in
                for index in data.indices{
                    // x 点的位置  宽度 / 总数 * 增量
                    let xPosition =  geometry.size.width / CGFloat(data.count) * CGFloat(index + 1)
                    
                    // Y 轴
                    let yAxis = maxY - minY
                    
                    // y 点的位置
                    let yPosition = (1 - CGFloat((data[index] - minY) / yAxis)) * geometry.size.height
                    
                    if index == 0 {
                        // 移至起始点左上角(0,0)
                        path.move(to: CGPoint(x: xPosition, y: yPosition))
                    }
                    // 添加一条线
                    path.addLine(to: CGPoint(x: xPosition, y: yPosition))
                }
            }
            // 修剪绘制线条的进度
            .trim(from: 0, to: percentage)
            .stroke(lineColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
            .shadow(color: lineColor, radius: 10, x: 0.0, y: 10)
            .shadow(color: lineColor.opacity(0.5), radius: 10, x: 0.0, y: 20)
            .shadow(color: lineColor.opacity(0.2), radius: 10, x: 0.0, y: 30)
            .shadow(color: lineColor.opacity(0.1), radius: 10, x: 0.0, y: 40)
        }
    }
    
    /// 背景
    private var chartBackground: some View{
        VStack{
            Divider()
            Spacer()
            Divider()
            Spacer()
            Divider()
        }
    }
    
    /// Y 轴坐标点文字
    private var chartYAxis: some View{
        VStack{
            Text(maxY.formattedWithAbbreviations())
            Spacer()
            Text(((maxY + minY) * 0.5).formattedWithAbbreviations())
            Spacer()
            Text(minY.formattedWithAbbreviations())
        }
    }
    
    /// X 轴日期文字
    private var chartDateLabels: some View{
        HStack {
            Text(startingDate.asShortDateString())
            Spacer()
            Text(endingDate.asShortDateString())
        }
    }
}

4.2 创建货币详情视图,DetailView.swift

Swift 复制代码
import SwiftUI

/// 加载交易货币详情页
struct DetailLoadingView: View{
    /// 交易货币模型
    @Binding var coin: CoinModel?
    
    var body: some View {
        ZStack {
            if let coin = coin{
                DetailView(coin: coin)
            }
        }
    }
}

/// 交易货币详情页
struct DetailView: View {
    @StateObject private var viewModel: DetailViewModel
    /// 是否展开概述内容
    @State private var showFullDescription: Bool = false
    
    // 网格样式
    private let colums: [GridItem] = [
        // flexible: 自动调整大小
        GridItem(.flexible()),
        GridItem(.flexible())
    ]
    // 网格间隔
    private let spacing: CGFloat = 30
    
    /// 交易货币模型
    init(coin: CoinModel) {
        _viewModel = StateObject(wrappedValue: DetailViewModel(coin: coin))
    }
    
    var body: some View {
        ScrollView {
            VStack {
                // 交易货币折线图
                ChartView(coin: viewModel.coin)
                // 间隔
                    .padding(.vertical)
                VStack(spacing: 20) {
                    // 概述信息标题
                    overviewTitle
                    Divider()
                    // 概述信息内容
                    descriptionSection
                    // 概述信息网格 View
                    overviewGrid
                    // 附加信息标题
                    additionalTitle
                    Divider()
                    // 附加信息网格 View
                    additionalGrid
                    //  网站地址
                    websiteSection
                }
                .padding()
            }
        }
        .background(Color.theme.background.ignoresSafeArea())
        .navigationTitle(viewModel.coin.name)
        .toolbar {
            ToolbarItem(placement: .navigationBarTrailing) {
                navigationBarTrailing
            }
        }
    }
}

extension DetailView{
    /// 导航栏右边 Item,建议使用 Toolbar
    private var navigationBarTrailing: some View{
        HStack {
            Text(viewModel.coin.symbol.uppercased())
                .font(.headline)
                .foregroundColor(Color.theme.secondaryText)
            CoinImageView(coin: viewModel.coin)
                .frame(width: 25, height: 25)
        }
    }
    
    /// 概述信息标题
    private var overviewTitle: some View{
        Text("Overview")
            .font(.title)
            .bold()
            .foregroundColor(Color.theme.accent)
            .frame(maxWidth: .infinity, alignment: .leading)
    }
    
    ///  附加信息标题
    private var additionalTitle: some View{
        Text("Additional Details")
            .font(.title)
            .bold()
            .foregroundColor(Color.theme.accent)
            .frame(maxWidth: .infinity, alignment: .leading)
    }
    
    /// 概述信息内容
    private var descriptionSection: some View{
        ZStack {
            if let description = viewModel.description,
               !description.isEmpty {
                
                VStack(alignment: .leading) {
                    // 描述文本
                    Text(description)
                        .lineLimit(showFullDescription ? nil : 3)
                        .font(.callout)
                        .foregroundColor(Color.theme.secondaryText)
                    // 更多按钮
                    Button {
                        withAnimation(.easeInOut) {
                            showFullDescription.toggle()
                        }
                    } label: {
                        Text(showFullDescription ? "Less" : "Read more...")
                            .font(.caption)
                            .fontWeight(.bold)
                            .padding(.vertical, 4)
                    }
                    .accentColor(.blue)
                }
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
    }
    
    /// 概述信息网格 View
    private var overviewGrid: some View{
        LazyVGrid(
            columns: colums,
            alignment: .leading,
            spacing: spacing,
            pinnedViews: []) {
                ForEach(viewModel.overviewStatistics) { stat in
                    StatisticView(stat:stat)
                }
            }
    }
    
    /// 附加信息网格 View
    private var additionalGrid: some View{
        LazyVGrid(
            columns: colums,
            alignment: .leading,
            spacing: spacing,
            pinnedViews: []) {
                ForEach(viewModel.additionalStatistics) { stat in
                    StatisticView(stat: stat)
                }
            }
    }
    
    /// 网站地址
    private var websiteSection: some View{
        VStack(alignment: .leading, spacing: 12){
            // 官方网站
            if let websiteString = viewModel.websiteURL,
               let url = URL(string: websiteString){
                Link("Website", destination: url)
            }
            
            Spacer()
            // 论坛网站
            if let redditString = viewModel.redditURL,
               let url = URL(string: redditString){
                Link("Reddit", destination: url)
            }
        }
        .accentColor(.blue)
        .frame(maxWidth: .infinity, alignment: .leading)
        .font(.headline)
    }
}

struct DetailView_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            DetailView(coin: dev.coin)
        }
    }
}

5. 效果图:

相关推荐
恋猫de小郭11 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
点金石游戏出海17 小时前
每周资讯 | Krafton斥资750亿日元收购日本动画公司ADK;《崩坏:星穹铁道》新版本首日登顶iOS畅销榜
游戏·ios·业界资讯·apple·崩坏星穹铁道
旷世奇才李先生19 小时前
Swift 安装使用教程
开发语言·ios·swift
90后的晨仔19 小时前
Xcode16报错: SDK does not contain 'libarclite' at the path '/Applicati
ios
finger2448020 小时前
谈一谈iOS线程管理
ios·objective-c
Digitally20 小时前
如何将大型视频文件从 iPhone 传输到 PC
ios·iphone
梅名智21 小时前
IOS 蓝牙连接
macos·ios·cocoa
菌菇汤1 天前
uni-app实现单选,多选也能搜索,勾选,选择,回显
前端·javascript·vue.js·微信小程序·uni-app·app
美狐美颜sdk1 天前
跨平台直播美颜SDK集成实录:Android/iOS如何适配贴纸功能
android·人工智能·ios·架构·音视频·美颜sdk·第三方美颜sdk
恋猫de小郭1 天前
Meta 宣布加入 Kotlin 基金会,将为 Kotlin 和 Android 生态提供全新支持
android·开发语言·ios·kotlin