TodayExtension (Widget)

本文将介绍如何在「今日试图」中添加一个扩展,主要以 iOS10 Widget 为主。首先扩展是附属在一个应用内的,也就是我们不能通过 AppStore 直接下载一个扩展,它一定是和宿主 app 一起安装。扩展有自己独立的生命周期。下面将介绍如何创建一个扩展,如何与宿主 app 进行交互。

demo 地址 : TodayExtension (Widget)

1. 添加 TodayExtendion target

点选菜单 File > New > Target…,在 iOS > Application Extension 可以找到 TodayExtension,命名确认后让 Xcode 自动生成新的 Scheme。这时项目中会有一个和刚刚新建的 target 的同名文件夹,里面包含了TodayViewController.swift MainInterface.storyboard info.plist 3个文件。可以看到,在storyboard里默认添加了一个「Hello Wrold」的 label。现在运行一下项目,一切正常的话,会看到如下图:

关于 widget 内容的展示,在 ViewController 里控制就行,具体想要展示的样子,开发者可以自由完成,这里就不展示了。

2. iOS10 Widget 高度调整

如上图,一个 widget 的高度默认是 110。

  • 在右上角显示「展开/折叠」按钮
1
2
3
4
5
6
// 需判断系统版本为10.0以上
if #available(iOSApplicationExtension 10.0, *) {
extensionContext?.widgetLargestAvailableDisplayMode = .expanded
} else {
// Fallback on earlier versions
}

  • 点击「展开/折叠」按钮的回调并控制高度
1
2
3
4
5
6
7
8
9
10
// 同样需要判断版本为10.0以上
@available(iOSApplicationExtension 10.0, *)
func widgetActiveDisplayModeDidChange(_ activeDisplayMode: NCWidgetDisplayMode, withMaximumSize maxSize: CGSize) {
switch activeDisplayMode {
case .compact:
preferredContentSize = CGSize(width: 0, height: 110)
case .expanded:
preferredContentSize = CGSize(width: 0, height: 220)
}
}

3. 使用 App Groups 共享扩展和主应用数据

由于沙盒的限制,我们不能直接在扩展和主应用间共享数据,这时候要用到 App Groups。App Groups 为同一个 vender 的应用或者扩展定义了一组域,在这个域中同一个 group 可以共享一些资源。

  • 开启 App Groups

选择主 target 的 Capabilities 选项,找到 App Groups 选项,后面有个开关开启。并添加一个 goups name。这里添加一个group.HelloTodayUserDefault 。然后同样的在扩展的 target 也进行同样的步骤。

  • 使用同一 groups 下的 userDefault 进行数据共享

为了方便演示,我在主 target 里添加了一个 textView。textView 的文本修改后,将通过 userDefault 将数据共享给扩展。看下代码:

在宿主 app 中更改了 textView 的值,同步到 group 的 userDefault 中

1
2
3
4
5
6
func textViewDidChange(_ textView: UITextView) {
// suiteName 与 Capabilities 添加的一致
let userDefault = UserDefaults(suiteName: "group.HelloTodayUserDefault")
userDefault?.set(textView.text, forKey: "textView.value")
userDefault?.synchronize()
}

在扩展中获取数据

1
2
let userDefault = UserDefaults(suiteName: "group.HelloTodayUserDefault")
label.text = userDefault?.value(forKey: "textView.value") as? String ?? "sth. wrong"

4. 扩展和主应用共享代码

一个最简单的方式是把需要公用的代码文件加入扩展 target 的编译文件中,但是这么做不好的地方是,当这些共用的文件越来越多,添加到 target 这种方式将难以管理和维护。iOS8 之后 apple 提供了一个更好的方式,做成 Framework。

点选 File > New > Target…,在 Framework & Library 可以找到 Cocoa Touch Framework,next 命名。

上面的代码里,suiteName 我都是直接使用字符串,这里很不好的就是万一两个地方写的不一样了,就有问题了。接下来我在 taget 里添加一个 swift 文件,用一个常量来记录这两个 key 值。

1
2
let groupSuiteName = "group.HelloTodayUserDefault"
let kTextValue = "textView.value"

首先在主程序的 ViewController 中 import 刚刚新建的 Framework。这时我将原来的字符串替换为常量,发现 Xcode 会提示未定义之类的。这是由于同一个 module 中默认 internal 访问层级,现在代码处于不同的 module。所以需要更改 swift 文件的权限,在需要在外部访问的地方加上 public 关键字

1
2
public let groupSuiteName = "group.HelloTodayUserDefault"
public let kTextValue = "textView.value"

接下来在 ViewController 中就可以直接使用了:

1
2
3
let userDefault = UserDefaults(suiteName: groupSuiteName)
userDefault?.set(textView.text, forKey: kTextValue)
userDefault?.synchronize()

同样的步骤,将 Framework 链接到扩展中

1
2
let userDefault = UserDefaults(suiteName: groupSuiteName)
label.text = userDefault?.value(forKey: kTextValue) as? String ?? "sth. wrong"

接下来编译可以通过,但是会收到一条警告:

这是由于作为插件,需要严格遵守沙盒限制,一些 API 是不能使用的。避免这个警告,需要在 Framework 中声明使用的是扩展可用的 API。切换到 framework target 中的 General 选项,在 Deployment Info 下面勾选 Allow app extension API only。关于不可用的 API,apple 都标注了 NS_EXTENSION_UNAVAILABLE

5. 从扩展进入主程序

扩展提供了 NSExtensionContext 类来与主应用交互,这里使用 open 方法就好了。

这里为了方便演示,我就直接给扩展的 view 添加一个点击手势了。由于这是一个 ViewController,我相信开发者会有很多操作的空间。

1
2
3
4
5
6
7
8
9
10
11
12
override func viewDidLoad() {
...
let tap = UITapGestureRecognizer(target: self, action: #selector(tapAction))
view.addGestureRecognizer(tap)
}

func tapAction() {
guard let url = URL(string: "todayDemo://helloToday") else {
return
}
extensionContext?.open(url, completionHandler: nil)
}

在主程序中添加对应的 URL Scheme

AppDelegate.swift 中捕获打开事件,做出对应操作:

1
2
3
4
5
6
7
func application(_ app: UIApplication, open url: URL, options: [UIApplicationOpenURLOptionsKey : Any] = [:]) -> Bool {
if url.scheme == "todayDemo" {
print(url.host) // -- helloToday
return true
}
return false
}

End.