面向 Extension 开发 🌞 Share Extension

Share Extension 使用户在使用其他的app 的时候, 更加方便的将其内容分享出去,像是社会化分享还有上传服务器。比如说, 在一个 app 中有个分享按钮, 用户可以选择其中一个 Share Extension 来发表评论或者内容。

写在前面的话

最好的 Share Extension 能够让用户能够很轻松的分享网页中的内容。如果你需要用一个扩展来让用户使用这些内容做一些其他的操作, 或者为用户提供他们所关心的内容的更新, Share Extension 可能就不是最好的解决方案了。

如何理解 Share Extension

Share Extension 有以下几个特点:

  • 让用户更容易分享内容。
  • 如果可以的话,能够让用户预览,编辑,标注,并且自定义内容。
  • 在用户发送内容的时候,能够确保内容是合法的。

用户能够通过系统提供的 UI 来获得他能够使用的 Share Extension。在 iOS 中, 用户点击分享按钮,然后从系统弹出来的分享区域中选择一个 Share Extension。

当用户选择了你的 Share Extension 之后,你需要展示一个包含了内容的视图,然后发表出去。你可以将你的视图机遇系统提供的 vc, 或者自定义一个。系统提供的那个提供了一些很常见的操作,比如说,预览,合法性判断,同步内容,以及视图的动画,还有设置发布。

创建 Share Extension

创建的过程类似于之前写的 面向 Extension 开发 🌞 Today Extension

唯一不同的是 Today Extension 有唯一的一个 宿主 app 而 Share Extension 在使用的时候, 可能有很多的宿主 app 所以在运行的时候,需要选择一个宿主 app。 一般都是选择的 Safari 然后,随便打开一个网页,下面的分享按钮就可用了,点击之后,在分享列表里面就能够看到你的 app 咯。

需要注意的是,这个时候看到的 Share Extension 的名称是你 Share Extension 的名称,这个是可以更app 名称不一样的。只要改 Share Extension 的 info.plist 中的 Bundle display name 为你想要的名称就可以了。

这篇文章要做什么?

写到这里, 基本上已经完成了准备工作了。可能还有 创建 app groups 之类的工作,这块将在下面的内容中介绍。花了几天时间断断续续的研究 Share Extension,对比了系统中本来就存在的facebook twitter 以及国内的微博什么的。我将在本文中模仿着做一个类似的效果出来。

这是最终效果的 gif 图。这只是第一步。好了,我们开始吧。

基本设置

1
2
3
4
5
override func viewDidLoad() {
super.viewDidLoad()
placeholder = "分享到微博" // 占位文字
charactersRemaining = 140 // 左下角的文字 展示数字,可以用来倒数,还能输入几个字, 小于等于0的时候变成红色
}

如注释所见,这里设置了placeholder 已经右下角的数字。

1
2
3
4
5
// 过滤分享的内容
override func isContentValid() -> Bool {
charactersRemaining = 140 - contentText.characters.count as NSNumber
return contentText.characters.count > 2
}

这段代码用来验证用户输入的内容是否合法。这里我只是简单的设置了内容的长度不能超过140,并且不能小于2.

系统在SLComposeServiceViewController中提供了open func didSelectPost()open func didSelectCancel() 两个方法分别是上面两个按钮的事件。

需要注意的是,重写 cancel 的时候,需要调用 super

接下来是设置位置,分组这些内容。这写也是在系统的api 中能找到对应的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
override func configurationItems() -> [Any]! {
// 定位
let item1 = SLComposeSheetConfigurationItem()
item1?.title = "位置"
item1?.value = "无"
item1?.valuePending = false
item1?.tapHandler = {
item1?.valuePending = true
// 在这里做定位的操作
// 模拟花了3s时间
delay(3, task: {
item1?.value = ""
item1?.valuePending = false
item1?.value = "四川省 成都市"
})
}
// 跳转
let item2 = SLComposeSheetConfigurationItem()
item2?.title = "可见组"
item2?.value = ""
item2?.tapHandler = {
let list = ListController()
list.callbackClosure = {
item2?.value = $0
}
self.pushConfigurationViewController(list)
}
// 测试预览
/*
let item3 = SLComposeSheetConfigurationItem()
item3?.title = "预览"
item3?.tapHandler = {
let pre = self.loadPreviewView()// 这个方法实际上是用来获取右边的图片的
pre?.frame = self.view.bounds
self.view.addSubview(pre!)
}
*/
return [item1!, item2!]
}

这个方法返回了一个数组,就是对应的按钮等内容。每个按钮其实也很简单。只有 titlevaluetapHandlervaluePending 四个属性。

  • title: 左边的文字
  • value: 右边的文字
  • tapHandler: 处理这个 item 事件的 closure
  • valuePending: 左边转菊花的indicator,是一个 bool 类型的属性。

在上面的代码里,我用 self.pushConfigurationViewController(list) 这行代码push 到了另外的界面,用来让用户选择他们要把消息分享到的具体分组。这个操作是在 Facebook 的 share extension 中看见的。在实际中,我们也可以这样做其他很多的事情。

需要注意的是,推出来的 Controller 需要设置背景为clear,cell 也要设置背景为 clear 这是为了保证界面跟系统统一(模糊效果)。

然后就是要把用户选择的内容分享出去了。

通过 Share Extension 分享内容

要将内容分享出去,需要解决几个问题。

  • 用户信息
  • 获取分享的内容

因为 App Extension 和主 App 是两个不同的 Target, 这就需要我们在这个获取到主 app 中用户的登录信息。至少需要知道我们要把内容分享到哪个用户的数据流中吧。

这个其实也是很简单的事情。在 Today 中我们已经知道了 App Groups 这个东西。也知道了如何共享部分代码。

所以在 Share Extension 中

1
2
3
4
5
func fetchUserInfomation() -> String? {
let userdefault = UserDefaults.init(suiteName: "group.sunny.com")
let info = userdefault?.value(forKey: "userInformation") as? [String: String]
return info?["token"]
}

然后在主app 中

1
2
3
let userdefault = UserDefaults(suiteName: "group.sunny.com")
userdefault?.set(["token": "this the user token"], forKey: "userInformation")
userdefault?.synchronize()

就实现了数据之间的交换。到这儿,可能会想到另外一个问题。如果没有登录的话需要跳转到主 app 中进行登录操作。这里也没有什么问题通过 openurl 就可以。

  1. 设置主app 的url type
  2. 跳转

所以我在 viewDidload 方法中添加了以下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if fetchUserInfomation() == nil {
let alert = UIAlertController(title: "还没有登录", message: nil, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "取消", style: .cancel) {_ in
self.cancel()
})
alert.addAction(UIAlertAction(title: "去登录", style: .default) {_ in
self.extensionContext?.open(NSURL(string: "sunny://action=login")! as URL, completionHandler: { (success) in
self.cancel()
print(success)
})
})
present(alert, animated: true, completion: nil)
}

判断登录状态,然后弹窗。取消或者去登录。如果选择去登录的话,就通过 openUrl 去打开主 app。

很完美吧!but it doesn’t work!!!, 我在 stackoverflow 上找到了些资料。

苹果爸爸只允许 Today Extension 通过 extensionContext 的 openUrl 打开主app

但是这个需求总是需要实现的。其实还是有解决方法。

方法一: 在 Extension 中实现登录操作

这个确实没什么好说的。也是弹出一个 alert,然后输入用户名,密码,登录。完成所有操作。或者是其他什么方案,都可以。这个就不再详细描述了。Share Extension 来实现登录行为,然后 主 app 也能够共享等了状态。这仿佛也是解决了这种问题。

当然,强迫症笔者,还是想通过打开主 app 的方法来解决这个问题。

方法二: 另类的 openUrl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// For skip compile error.
func openURL(_ url: URL) {
return
}
func openContainerApp() {
var responder: UIResponder? = self as UIResponder
let selector = #selector(openURL(_:))
while responder != nil {
if responder!.responds(to: selector) && responder != self {
responder!.perform(selector, with: URL(string: "sunny://action=login")!)
return
}
responder = responder?.next
}
}

当然,上面的两个链接还有一些其他的方法,就不一一列举了。

解决了最开始的用户信息的问题。接下来就是要获取分享的内容这个问题了。在ShareExtension 中,相信已经看见了。需要两个东西,第一个是用户关于这个内容的评论,以及这个内容本身(url、照片等)。关于用户对内容的评论这点其实很简单。

用户评论
1
2
// Convenience. This returns the current text from the textView.
open var contentText: String! { get }

系统提供的这个 api 就能够解决这个问题。

附件内容

暂且叫做附件内容吧!我也不知道应该怎么叫。这个东西,我们还是看看 extensionContext 这个东西吧!

NSExtensionContext 这个类一共暴露了四个api出来。我们看第一个

1
2
// The list of input NSExtensionItems associated with the context. If the context has no input items, this array will be empty.
open var inputItems: [Any] { get }

看样子就是这个了。

看注释内容,突然感觉,apple 的api 也有设计的不是很好的地方,既然注释都明确说了 NSExtensionItems 数组应该不是 Any 的吧😂

既然这样, 我们再看看 NSExtensionItem 这个类吧!

1
2
3
4
5
6
7
8
// (optional) title for the item
@NSCopying open var attributedTitle: NSAttributedString?
// (optional) content text
@NSCopying open var attributedContentText: NSAttributedString?
// (optional) Contains images, videos, URLs, etc. This is not meant to be an array of alternate data formats/types, but instead a collection to include in a social media post for example. These items are always typed NSItemProvider.
open var attachments: [Any]?
// (optional) dictionary of key-value data. The key/value pairs accepted by the service are expected to be specified in the extension's Info.plist. The values of NSExtensionItem's properties will be reflected into the dictionary.
open var userInfo: [AnyHashable : Any]?

注释太复杂了,整理成一个表格就是这样的:

Properties Description
attributedTitle 标题 optional
attributedContentText 内容 optional
attachments 所有的附件NSItemProvider组成一个数组 optional
userInfo 一个key-value结构的数据。NSExtensionItem中的属性都会在这个属性中一一映射。注释中讲到的在 info.plist 中要设置的部分会在后面提到

下面的表格就是 userInfo 中的 key :

名称 说明
NSExtensionItemAttributedTitleKey 标题 的键名
NSExtensionItemAttributedContentTextKey 内容 的键名
NSExtensionItemAttachmentsKey 附件 的键名

上面又提到了 NSItemProvider 这个东西。这相必须就是我们需要的附件了吧!

Api description
initWithItem:typeIdentifier: 初始化方法,item为附件的数据,typeIdentifier是附件对应的类型标识,对应UTI的描述。
initWithContentsOfURL: 根据制定的文件路径来初始化。
registerItemForTypeIdentifier:loadHandler: 为一种资源类型自定义加载过程。这个方法主要针对自定义资源使用,例如自己定义的类或者文件格式等。当调用loadItemForTypeIdentifier:options:completionHandler:方法时就会触发定义的加载过程。
hasItemConformingToTypeIdentifier: 用于判断是否有typeIdentifier(UTI)所指定的资源存在。存在则返回YES,否则返回NO。该方法结合loadItemForTypeIdentifier:options:completionHandler:使用。
loadItemForTypeIdentifier:options:completionHandler: 加载typeIdentifier指定的资源。加载是一个异步过程,加载完成后会触发completionHandler。
loadPreviewImageWithOptions:completionHandler: 加载资源的预览图片。

这时候看看整体的结构:(这个图是在看到的)

到这里,应该已经知道了应该怎么做了吧!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 点击发表的事件
override func didSelectPost() {
self.extensionContext?.inputItems.forEach({ (item) in
print("//////////////////////////")
let ext = item as! NSExtensionItem
ext.attachments?.forEach({
let atta = $0 as! NSItemProvider
print(atta)
// 分享的是网页
if atta.hasItemConformingToTypeIdentifier("public.url") {
atta.loadItem(forTypeIdentifier: "public.url") { (item, error) in
print("//////////////////////////")
print(item!)
}
print("//////////////////////////")
}
// 分享的是图片
if atta.hasItemConformingToTypeIdentifier("public.jpeg") {
atta.loadItem(forTypeIdentifier: "public.jpeg") { (item, error) in
print("//////////////////////////")
print(item!)
}
print("//////////////////////////")
}
})
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
})
}

代码中分别是分享网页和图片两个东西。这一步解决了找到分享的内容的代码。

具体分享的行为可以有两个办法来解决

  • 将需要分享的内容功过 apps group 保存,然后在打开主 app 的时候,在主 app 中取出然后发送给sever。
  • 直接在 Share Extension 中分享。

这个过程就不再叙述了。

info.plist

既然说到了 info.plist 中的设置,就再看看这部分是说的什么吧!都是一些很固定的内容,我随便挑两个说说吧!

Key Description
NSExtensionActivationSupportsAttachmentsWithMaxCount 附件最大个数
NSExtensionActivationSupportsAttachmentsWithMinCount 附件最小个数
NSExtensionActivationSupportsFileWithMaxCount 附件种类限制
NSExtensionActivationSupportsMovieWithMaxCount 视频个数限制
NSExtensionActivationSupportsImageWithMaxCount 图片个数限制
NSExtensionActivationSupportsText 是否支持文本类型
NSExtensionActivationSupportsWebURLWithMaxCount web 链接最多限制
NSExtensionActivationSupportsWebPageWithMaxCount web 页面最多限制

如果要设置你的 extension 只支持图片,url 什么的。只需要把个数限制写成 0!

但是设置的时候需要注意是将NSExtensionActivationRule 改成 Dictionary 类型并添加:

  • NSExtensionActivationSupportsAttachmentsWithMaxCount
  • NSExtensionActivationSupportsAttachmentsWithMinCount
  • NSExtensionActivationSupportsImageWithMaxCount
  • NSExtensionActivationSupportsMovieWithMaxCount
  • NSExtensionActivationSupportsWebPageWithMaxCount
  • NSExtensionActivationSupportsWebURLWithMaxCount

这就基本上完成了,我们要在 系统或者 外部 app 中将内容分享到我们自己的 app 中。这好像还是有很大的限制。毕竟如果我们的产品不是像微博qq这样的社交app 的话,这个东西就没什么作用了。

另外注意这个警告

在自己的app 中调起 Share Extension

1
2
3
4
let activity = UIActivityViewController(activityItems: ["百度", URL(string: "http://www.baidu.com")!], applicationActivities: nil)
// 不分享到 airDrop 和 粘贴板
activity.excludedActivityTypes = [.airDrop, .copyToPasteboard]
present(activity, animated: true, completion: nil)

当然还有 UIActivityViewControllerCompletionHandler 这个东西,来回调分享的结果。

另外一种方法可以直接调起某个系统的分享。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 判断是否支持 微博
if !SLComposeViewController.isAvailable(forServiceType: SLServiceTypeSinaWeibo) {
// 应该是没有登录的原因, 所以一直不会返回
print("不可用")
return
}
let composeVC = SLComposeViewController(forServiceType: SLServiceTypeSinaWeibo)
// // 添加要分享的图片
// composeVC?.add(UIImage(named: "Nameless"))
// // 添加要分享的文字
// composeVC?.setInitialText("分享到XXX")
// // 添加要分享的url
// composeVC?.add(URL(string: "http://www.baidu.com"))
// // 弹出分享控制器
self.present(composeVC!, animated: true, completion: nil)
// // 监听用户点击事件
composeVC?.completionHandler = {
if $0 == .done {
NSLog("点击了发送");
} else if $0 == .cancelled {
NSLog("点击了取消");
}
}

这种方式有一个缺陷,就是,这样的分享只能对系统的分享,微信什么的就不能这么做了。

最后的话

Share Extension 写到这里就差不多了。初步的入门步骤也已经完成了。最后,我看了一下,微信的 Share Extension 做的事情,感觉用他还能做很多的事情。这个也需要在开发中根据实际需求去拓展了,另外还有自定义 UI 等,也是很简单的事情。只是用自己 UIViewController 就好了。这个就不再详细的说了。到此,我能想到的功能,就基本上完成了。如果有更多需求也可以跟我讨论。

demo地址

CepheusSun wechat
订阅我的公众号,每次更新我都不一定会告诉你!
坚持原创技术分享,您的支持将鼓励我继续创作!
0%