2022-04-02 -- iOS 14 widget 小组件开发

widget 效果展示.jpeg
import WidgetKit
import SwiftUI
import Alamofire
import HandyJSON

/// 定义模型
struct Model {
    var coin: String
    var USD: String
    var CNY: String
    var Range: String
}

/********************************************
 //  定义网络请求
********************************************** */
struct ApiRequest {

    /// 网络请求
    /// - Parameters:
    ///   - coins: 请求的币种数组
    ///   - completion: 处理的模型数组
    static func apiRequest(coins: [[String: String]],completion:  @escaping (Result<[Model], Error>) -> Void) {
        var models: [Model] = []
        for (_,dic) in coins.enumerated() {
            let url = URL(string: "http://xxxxxxxxxxxxxx?symbol=\(dic["symbol"]!)")
            /// URLSession 设置头文件
            var request = URLRequest.init(url: url!)
            request.method = .get
            request.addValue("IOS", forHTTPHeaderField: "way")
            request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content")

            let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                guard error == nil else {
                    /// MARK: -- 即使数据请求失败,也需要创建一个默认的模型传递过去, 不然会出现列表模糊不清的情况
                    models.append(Model(coin: dic["symbol"]!, USD: "-- error", CNY: "--", Range: "--"))
                    /// MARK:-- 强制返回数据的数量 和 需要请求的数量一致,否则会出现数据丢失的情况
                    if models.count == coins.count {
                        completion(.success(models))
                    }
                    return
                }
                /// 数据的处理在这里要全部完成, 需要图片下载也应同步处理下载并处理。 
                var poetry = poetryFromJson(fromData: data!)
                if poetry.coin == "加载失败" {
                    /// 数据解析失败后。展示名称 ,其余展示 “ -- ” 即可
                    poetry.coin = dic["symbol"]!
                }
                models.append(poetry)

                /// MARK:-- 强制返回数据的数量 和 需要请求的数量一致,否则会出现数据丢失的情况
                /// 不能以 idx == coins.count - 1 的判断来返回数据。 必须等到数据全部处理完毕
                if models.count == coins.count {
                    completion(.success(models))
                }
            }
            task.resume()
        }
    }

    /// 解析 网络请求 数据
    /// - Parameter data: 请求的数据
    /// - Returns: 数据模型
    static func poetryFromJson(fromData data: Data) -> Model {
        let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
        guard let data = json["data"] as? [String: Any] else {
            /// 数据解析失败时, 返回默认的model
            return Model(coin: "加载失败", USD: "--", CNY: "--", Range: "--")
        }

        /// 测试显示和刷新的时间
        let currentDate = Date()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH时mm分ss秒"
        let dateStr = dateFormatter.string(from: currentDate)

        let coin = data["symbol"] as! String
        let open = dateStr
        let amount = data["amount"] as! String
        let vol = "-2.2%"
        return Model(coin: coin, USD: open, CNY: amount, Range: vol)
    }

    ///  模拟数据
    ///  只做本地数据展示用。调试列表样式时可用
    static func modelForJson() -> [Model] {
        var models: [Model] = []
        let currentDate = Date()
        //设定15秒更新一次数据
        let updateDate = Calendar.current.date(byAdding: .second, value: 15, to: currentDate)!
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "HH时mm分ss秒"
        let dateStr = dateFormatter.string(from: updateDate)

        let titles = [["coin":dateStr,"USD":"11111","CNY":"¥1111","Range":"+5.5%"],
                      ["coin":"ETH/USDT","USD":"222222","CNY":"¥22222","Range":"+1.5%"],
                      ["coin":"HT/USDT","USD":"33333","CNY":"¥33333","Range":"-6.1%"],
                      ["coin":"ICP/USDT","USD":"4444","CNY":"¥444444","Range":"+3.50%"],
                      ["coin":"XRP/USDT","USD":"55555","CNY":"¥55555","Range":"-5.56%"]]
        for item in titles {
            let temp = Model(coin: item["coin"]!, USD: item["USD"]!, CNY: item["CNY"]!, Range: item["Range"]!)
            models.append(temp)
        }
        return models
    }
}

struct Provider: TimelineProvider {
    /// 预览数据
    static  let titles = [["coin":"BTC/USDT","USD":"48059","CNY":"¥248202","Range":"+5.5%"],
                          ["coin":"ETH/USDT","USD":"3399.52","CNY":"¥33885","Range":"+1.5%"],
                          ["coin":"ETC/USDT","USD":"9.2991","CNY":"¥48822","Range":"-6.1%"],
                          ["coin":"ICP/USDT","USD":"22.19","CNY":"¥120052","Range":"+3.50%"],
                          ["coin":"XRP/USDT","USD":"0.008527","CNY":"¥8522","Range":"-5.56%"],
                          ["coin":"ADA/USDT","USD":"0.008527","CNY":"¥8522","Range":"-5.56%"]]

    /// 占位视图
    func placeholder(in context: Context) -> SimpleEntry {
        var models: [Model] = []
        for item in Provider.titles {
            let temp = Model(coin: item["coin"]!, USD: item["USD"]!, CNY: item["CNY"]!, Range: item["Range"]!)
            models.append(temp)
        }
        return SimpleEntry(date: Date(), models: models)
    }

    /// 快照
    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        var models: [Model] = []
        for item in Provider.titles {
            let temp = Model(coin: item["coin"]!, USD: item["USD"]!, CNY: item["CNY"]!, Range: item["Range"]!)
            models.append(temp)
        }
        let entry = SimpleEntry(date: Date(), models: models)
        completion(entry)
    }

    
    /// 数据更新
    func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) {
        //  设定5分钟更新一次数据,设置5秒更新也不起作用,系统决定更新时间,大概在5分钟左右
        let currentDate = Date()
        let updateDate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)
        let titles = [["symbol":"BTC/USDT"],
                      ["symbol":"ZRX/USDT"],
                      ["symbol":"VET/USDT"],
                      ["symbol":"XMR/USDT"],
                      ["symbol":"XLM/USDT"],
                      ["symbol":"XLM/USDT"]]
        ApiRequest.apiRequest(coins: titles) { result in
            var models: [Model]
            if case .success(let response) = result {
                models = response
            } else {
                models = [Model(coin: "--", USD: "--", CNY: "--", Range: "--")]
            }
            let entry = SimpleEntry(date: currentDate, models: models)

            /// MARK:-- 此处需要特别注意⚠️
            /// MARK:-- 在数据全部处理完毕之后进行传递。
            /// MARK:--  .after(updateDate!) 在设定的 5分钟后更新数据,实际更新时间是由系统控制的。大概在 5-6 分钟之间
            let timeline = Timeline(entries: [entry], policy: .after(updateDate!))
            completion(timeline)
        }
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    /// 相当于 模型数组
    let models: [Model]
}

/// 自定义View
struct StarexWidgetListView: View {
    /// MARK:-- 貌似直接设置Model 取值时会有错误。
    var coin: String
    var USD: String
    var CNY: String
    var Range: String
    var RangeColor: Color

    var body: some View {
        let height: CGFloat = 30
        /// MARK: -- 添加点击事件。
        /// 尝试在 StarexWidgetEntryView 的 ForEach 中添加时会导致布局错乱
        Link(destination: URL(string: "url://Coin---\(coin)")!) {
            HStack(alignment: .center, spacing: 0) {
                Text(coin)
                    .font(.system(size: 15))
                    .multilineTextAlignment(.leading)
                    .frame(height: height, alignment: .center)
                    .foregroundColor(Color.black)
                    .padding(10)
                
                Spacer()
                
                VStack(alignment: .trailing, spacing: 2) {
                    Text(USD)
                        .font(.system(size: 15))
                        .multilineTextAlignment(.trailing)
                        .frame(height: height/2, alignment: .top)
                        .foregroundColor(Color.black)
                    
                    Text(CNY)
                        .font(.system(size: 12))
                        .multilineTextAlignment(.trailing)
                        .frame(height: height/2, alignment: .top)
                        .foregroundColor(Color.gray)
                }.frame(maxHeight: height)

                Text(Range)
                    .font(.system(size: 14))
                    .multilineTextAlignment(.center)
                    .frame(width: 70,height: height, alignment: .center)
                    .foregroundColor(Color.white)
                    .background(RangeColor)
                    .cornerRadius(4)
                    .padding(EdgeInsets(top: 0, leading: 10, bottom: 0, trailing: 10))
            }
            .frame(maxHeight: height,alignment: .center)
            .environment(\.colorScheme, .dark)
        }
    }
}

// View
struct StarexWidgetEntryView : View {
    var entry: Provider.Entry
    @State private var selected = 0
    var body: some View {
        GeometryReader { geometry in
            VStack {
                HStack(alignment: .center, spacing: 4) {
                    Image("icon")
                        .resizable()
                        .frame(width: 28, height: 28)
                        .scaledToFill()
                        .ignoresSafeArea(.all)
                    Text("xxxx行情")
                        .foregroundColor(Color.gray)
                        .frame(width: 100,height: 40)
                        .multilineTextAlignment(.leading)
                        .font(.system(size: 15))
                    Spacer()
                }

                if entry.models.count == 0 {
                    Text("请先添加自选币种")
                        .foregroundColor(Color.gray)
                        .font(.title)
                        .multilineTextAlignment(.center)
                } else {

                    // 循环添加视图。
                    ForEach(0 ..< entry.models.count) { idx in
                        // Link 需要在 自定义 view 中添加,在这里添加时会造成布局错乱
                        // Link(destination:URL(string: "url://Coin ----- \(self.entry.models[idx].coin)")!) {
                        StarexWidgetListView(coin: self.entry.models[idx].coin,
                                            USD: self.entry.models[idx].USD,
                                            CNY: self.entry.models[idx].CNY,
                                            Range: self.entry.models[idx].Range,
                                            RangeColor: .red)
                        // widget 在此添加,点击时获取 URl 固定是最后一行url。 会导致数据获取不准确。
                        // 比如 ForEach 添加 5 行数据, 点击了 第一行, 获取的还是第五行的下标
                        // .widgetURL(URL(string: "url://Coin---\(self.entry.models[idx].coin)"))
                        if idx != entry.models.count - 1 {
                            Divider().padding(EdgeInsets(top: 2, leading: 10, bottom: 0, trailing: 0))
                        }
                    // }
                    }
                }
            // padding 给整个视图添加边距。由系统自动设置
            }.padding()
        }
    }
}


@main
struct StarexWidget: Widget {
    /// widget 主程序入口,这里只支持最大控件,需要支持中小控件,
    /// 可通过 @Environment(\.widgetFamily) var family: WidgetFamily 获取不同样式,然后分别返回不用的View
    let kind: String = "StarexWidget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            StarexWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("币种行情")
        .description("行情速递,行情趋势")
        /// 设置只支持大控件
        .supportedFamilies([.systemLarge])
    }
}


// MARK: 字符串转字典
extension String {
    func toDictionary() -> [String : Any] {
        var result = [String : Any]()
        guard !self.isEmpty else { return result }
        guard let dataSelf = self.data(using: .utf8) else {
            return result
        }
        if let dic = try? JSONSerialization.jsonObject(with: dataSelf,
                           options: .mutableContainers) as? [String : Any] {
            result = dic
        }
        return result
    }
}

刷新Widget

刷新widget 的语法只支持 swift。主项目是 OC时, 需要创建桥接文件


import Foundation
import WidgetKit

@available(iOS 14.0, *)
@objcMembers class WidgetManage: NSObject {

    static func reloadWidget(){
         /// 刷新全部 widget。还可以指定刷新其中一个widget
        WidgetCenter.shared.reloadAllTimelines()
    }
}

然后在 AppdelegatedidFinishLaunchingWithOptions 调用刷新,也可在 app 退出后刷新一次。

URL Link 点击跳转处理

OC

// widget
-(BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options{
    if ([url.scheme isEqualToString:@"StarexWidget"]){
        //执行跳转后的操作
    }

    NSLog(@"widget 跳转:\n url.absoluteURL:%@ - \nurl.absoluteString:%@ - \nurl.relativeString%@ - \nurl.scheme%@",url.absoluteURL,url.absoluteString,url.relativeString,url.scheme);
    return YES;
}

Swift -- 没有 ScreenDelegate 的情况下

 func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
        
        if url.relativeString == "qwer" {
            print("touchup!!!")
        }
        
        if url.relativeString == "url://123" {
             //TODO:
            print("哈哈哈哈哈哈哈url://123")
        }
        
        print("跳转链接:  \(url.absoluteURL) --- \(url.scheme) -- \(url.relativeString)")
        return true
    }

Pods 共享

目前好像只支持 swift 库。 OC库即使共享了也导入不了

  platform :ios, '9.0'
  use_frameworks!
  
# 共享 Pods
# 需要共享给 widget 的库需要单独写在外部
def share_pods
    pod 'HandyJSON'
end

# 第一个 Widget
target 'TestWidgetExtension' do
    share_pods
end

# 第二个 Widget
target 'CafeineDrinkExtension' do
  share_pods
end


# 主工程
target 'WWXHCamera' do
    pod 'Alamofire'
    share_pods

end

你可能感兴趣的:(2022-04-02 -- iOS 14 widget 小组件开发)