iOS14 Widget的使用

了解Widget
官方学习Widget Demo

Widgets显示相关的、可浏览的内容,允许用户快速访问你的App以获取更多详细信息。你的App可以提供多种小组件,让用户专注于对他们最重要的信息。

为我们的App添加新的Widget必须要明白其本身的限制:

  1. 弱交互。与之前仅可放置在负一屏的小部件相比,新版 Widget 只能借由点击固定的区域,打开主 app 内的特定页面或跳转链接。无法在不开启 app 的情况下,完成各种交互。
  2. 展示框架限定。新的 widget 仅允许使用苹果在去年(2019 年)才推出的 SwiftUI 进行布局,而不是开发者更熟悉的 UIView 框架。SwiftUI 作为一种申明式的页面布局方式,会默认的符合 苹果标准的留白和间隙,从某种角度而言自由度没有 UIView 那么高。好处是视觉统一性会更好,苹果味儿更足,坏处是会有些雷同。
  3. 刷新频率。为了省电,所有 widget 的刷新频率也是由系统统一调度,会根据用户的使用频率来调节。

根据这些我们可以知道,使用Widget,主要就是用来展示非常重要的信息,作为快捷入口等。尽量简单,不要将Widget搞成很复杂的东西。

下边主要贴代码进行一下说明,不过关于Widget的创建这里就省略了,毕竟网上多得是,例如:创建一个Widget Extension

一、布局

  1. 关于ZStack、HStack、VStack的使用。做Widget当然要先了解SwiftUI的使用,而在SwiftUI中这个三种Stack是必不可少的。可参考:您一直在等待的完整SwiftUI文档(需要科学上网),另外一个就是要学会Spacer()的使用,这个很重要。
  2. 封装。因为你的小组件可能有各种尺寸的,且可能需要多个小组件。因此尽可能的封装到每一个小的按钮,以便重复使用。
  3. 尺寸自适应。前边的限制中提到SwiftUI有自身的标准,因此可能与你的设计稿不相符,而获取到当前Widget的大小就很重要了(不仅仅只获取当前小组件的大小类型)。
  4. 黑暗模式。基本现在大家都需要适配黑暗模式吧?那么Widget也需要,而且因为SwiftUI的原因,我们可以非常简单的实现它。比如你在Widget的Assets中Add New Asset时选择Color set,就可以看到白色和黑暗模式两种颜色,按照你的需要设置色值之后,在代码中使用名字就可以了,系统会根据当前的模式自动选择颜色。而图片的使用则需要代码去判断了,以上的几条内容都可以在下边的示例代码中找到。
// 代码示例

struct OneBtnForFeaturesOneView: View {
    let btnTitle: String
    let btnUrl: String
    
    @Environment(\.colorScheme)  var colorScheme
    //view使用@ViewBuilder声明,因为它使用的view类型不同。
    @ViewBuilder
    var body: some View {
        Link(destination: URL(string: btnUrl)!){
            ZStack {
                Color("color_btn_bg")
                    .cornerRadius(16.0)
                VStack {
                    HStack(alignment: .top, content: {
                        Image(uiImage: UIImage(named: colorScheme == .dark ? "white_icon_search" : "black_icon_search")!)
                            .resizable()
                            .frame(width: 20, height: 20, alignment: .center)
                        Text(btnTitle)
                            .foregroundColor(Color("color_btn_title"))
                            .font(.system(size: 14, weight: .medium, design: .default))
                    })
                    .padding(.all, 16)
                    Spacer()
                    HStack (alignment: .bottom, content: {
                        Spacer()
                        Image(uiImage: UIImage(named: colorScheme == .dark ? "img_logo_white" : "img_logo")!)
                            .resizable()
                            .frame(width: 28, height: 28, alignment: .center)
                    })
                    .padding(.all, 16)
                }
            }
        }
    }
}


struct NewWidgetEntryView : View {
    var entry: Provider.Entry
    //针对不同尺寸的 Widget 设置不同的 View
    @Environment(\.widgetFamily) var family // 尺寸环境变量
    
    //view使用@ViewBuilder声明,因为它使用的view类型不同。
    @ViewBuilder
    var body: some View {
        // 使用 GeometryReader 获取小组件的大小
        GeometryReader{ geo in
            ZStack {
                Color("color_theme")
                    .frame(width: geo.size.width, height: geo.size.height, alignment: .center)
            }
        }
    }
}

在以上代码中,我们就可以看到与布局相关的内容

  • OneBtnForFeaturesOneView这个函数本身就是一个Button的封装,传入对应的Title和Url即可展示与跳转
  • 在这两个函数中我们可以看到ZStack、VStack的使用
  • colorScheme == .dark 即表示当前为黑暗模式,然后判断需要展示的图片
  • Color(“color_btn_title”) 即自动根据当前模式展示我们设置好的颜色
  • GeometryReader{ geo in }方法可以得到当前Widget的尺寸大小geo.size

二、数据请求

  1. 使用Swift的方式。
  2. 封装。
  3. 请求失败/默认数据的处理。在添加小组件的页面看到的样式就是加载的默认数据的样式,还有因为网络的问题请求失败的样式,将默认数据封装一下返回即可。
struct PosterData {
    static func getTodayPoster(completion: @escaping (Result<Poster, Error>) -> Void) {
        let url = URL(string: "https://nowapi.navoinfo.cn/get/now/today")!
        let task = URLSession.shared.dataTask(with: url) { (data, response, error) in
            guard error==nil else{
                completion(.failure(error!))
                return
            }
            let poster=posterFromJson(fromData: data!)
            completion(.success(poster))
        }
        task.resume()
    }
    
    static func posterFromJson(fromData data:Data) -> Poster {
        let json = try! JSONSerialization.jsonObject(with: data, options: []) as! [String: Any]
        guard let result = json["result"] as? [String: Any] else{
            return Poster(author: "Now", content: "加载失败")
        }
        
        let author = result["author"] as! String
        let content = result["celebrated"] as! String
        let posterImage = result["poster_image"] as! String
        
        //图片同步请求
        var image: UIImage? = nil
        if let imageData = try? Data(contentsOf: URL(string: posterImage)!) {
            image = UIImage(data: imageData)
        }
        
        return Poster(author: author, content: content, posterImage: image)
    }
}

以上为一个请求示例,接口可用,类似于带图片的每日名言。来自:iOS14 Widget小组件开发(Widget Extension)

三、多个小组件以及点击跳转事件

多小组件非常的简单,其最重要的代码就是如下:

@main
struct Widgets: WidgetBundle {
    @WidgetBundleBuilder
    var body: some Widget {
        NewWidget()
        FeaturesFourWidget()
        FeaturesTwoWidget()
    }
}

添加 WidgetBundle{},然后将@main给它,在内部实现各自的一套小组件就可以了,而每套小组件都是小中大三种样式。

参考:iOS 14 小组件(2):WidgetExtension 自定义样式与交互

不过其中有一段是不对的。
iOS14 的Widget小组件可以使用AppDelegate中的 openURL 来打开。
而关键就在于,对于最小格式的systemSmall必须使用widgetURL,但是对于中大型的小组件,你的点击内容就需要包裹在Link中,记得封装就行,这样就很简单了。

主要参考的文档有:
Build Your First Widget in iOS 14 With WidgetKit(科学上网)

对了,还有个踩坑的,有问题可以看下。
iOS小组件Widget踩坑