本文介紹如何使用快捷指令結合觸控功能薄腻,實現(xiàn)在不打開app的情況下板惑,通過長按屏幕彈出搜題浮窗。
1. 添加Intent
我們需要在工程內(nèi)引入Intents Extension蛀缝、Intents UI Extension。其中Intents Extension用于處理快捷指令阎毅,Intents UI Extension用于設置快捷指令觸發(fā)后的浮窗UI焚刚。
添加Intent Definition File,這是個定義文件净薛,我們可以在其中添加我們app支持的快捷指令。
我們把intent.intentdefinition的app蒲拉、Intents肃拜、IntentsUI的target都勾選上,以便它們都能調(diào)用我們的自定義Intent類雌团。
添加新的Intent燃领,并配置相應的選項:
- Category 設置Intent類型,不同的類型彈窗的UI和交互略有不同
- Custom Class 系統(tǒng)默認生成的Intent只讀頭文件锦援,包含Intent對應的類和handler需遵循的協(xié)議
- User confirmation required 會先彈一個包含 下一步 和 取消 按鈕的彈窗
- Intent is user-configurable in the Shortcuts app and Add to Siri 指令是否能在快捷指令app中找到
- 為Intent添加一個參數(shù) image 猛蔽,并將其type選擇為File,F(xiàn)ile Type選擇image
- 在Shortcuts app中的Input parameter選中 image 灵寺, 將上一個指令的輸出曼库,作為 image 參數(shù)輸入
添加完Intent后,需要修改主app略板、Intents毁枯、IntentsUI的info.plist, 添加對SearchQuestionIntent的關聯(lián)
?? Swift編譯問題解決方案
當使用swift時添加 image 參數(shù)編譯會報錯。這是因為在SearchQuestionIntent.swift中兩個swift方法指向了同一個objc方法導致的叮称。這個文件由Xcode自動生成的只讀文件种玛,我們沒法修改。這是Xcode 14的bug瓤檐,不過我使用Xcode 15.3也復現(xiàn)了赂韵。解決的辦法是:升級Xcode或者添加Extension的時候選擇Objective-C語言。參考:xcode-14-release-notes
2. 代碼實現(xiàn)要點
現(xiàn)在我們已經(jīng)配置完快捷指令了挠蛉,之后需要在代碼層面進行處理祭示。
2.1 AppDelegate
修改AppDelegate內(nèi)關于UserActivity的代理方法,這個是快捷指令打開app時觸發(fā)(包括指令直接調(diào)起app谴古,以及點擊siri浮窗進入等情況)绍移。
- (BOOL)application:(UIApplication *)application willContinueUserActivityWithType:(NSString *)userActivityType {
return YES;
}
- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {
INIntent *intent = userActivity.interaction.intent;
if (intent) {
//需 #import "SearchQuestionIntent.h"
if ([intent isKindOfClass:[SearchQuestionIntent class]]) {
if (@available(iOS 13.0, *)) {
SearchQuestionIntent *sqIntent = (SearchQuestionIntent *)intent;
INFile *imageFile = sqIntent.image;
if (imageFile.fileURL) {
// UIImage *image = [UIImage imageWithContentsOfFile:imageFile.fileURL.path];
// 點擊浮窗區(qū)域跳轉進app
// 處理代碼省略
}
}
}
}
return YES;
}
2.2 IntentHandler
修改IntentHandler文件,返回我們的自定義Handler
- (id)handlerForIntent:(INIntent *)intent {
// This is the default implementation. If you want different objects to handle different intents,
// you can override this and return the handler you want for that particular intent.
if ([intent isKindOfClass:[SearchQuestionIntent class]]) {
return [SearchQuestionIntentHandler new];
}
return self;
}
SearchQuestionIntentHandler需要實現(xiàn) SearchQuestionIntentHandling 的代理方法讥电。我們這里實現(xiàn)了3個方法蹂窖,他們的調(diào)用順序是 comfirm -> resolveImage -> handle 。handle 方法返回了一個帶有code的response恩敌,code是在 SearchQuestionIntent.h 中定義的枚舉瞬测,每個code對應一種狀態(tài),其彈窗和交互也略有不同。比如 ContinueInApp 是不彈窗直接跳轉到app月趟,Success 彈出一個帶有勾號的彈窗灯蝴,InProgress 也是彈窗但不帶勾號。(具體的UI和交互可能在不同的iOS系統(tǒng)版本下略有不同)
#import "SearchQuestionIntentHandler.h"
#import "SearchQuestionIntent.h"
@interface SearchQuestionIntentHandler() <SearchQuestionIntentHandling>
@end
@implementation SearchQuestionIntentHandler
#pragma mark - SearchQuestionIntentHandling
// 調(diào)用順序: comfirm -> resolveImage -> handle
//- (void)confirmSearchQuestion:(SearchQuestionIntent *)intent completion:(void (^)(SearchQuestionIntentResponse * _Nonnull))completion {
//}
- (void)handleSearchQuestion:(nonnull SearchQuestionIntent *)intent completion:(nonnull void (^)(SearchQuestionIntentResponse * _Nonnull))completion {
NSUserActivity *userActivity = [[NSUserActivity alloc] initWithActivityType:NSStringFromClass([SearchQuestionIntent class])];
SearchQuestionIntentResponse *response = [[SearchQuestionIntentResponse alloc] initWithCode:SearchQuestionIntentResponseCodeInProgress userActivity:userActivity];
completion(response);
}
- (void)resolveImageForSearchQuestion:(nonnull SearchQuestionIntent *)intent withCompletion:(nonnull void (^)(INFileResolutionResult * _Nonnull))completion API_AVAILABLE(ios(13.0)){
INFile *image = intent.image;
if (image != nil) {
INFileResolutionResult *result = [INFileResolutionResult successWithResolvedFile:intent.image];
completion(result);
} else {
// image 參數(shù)無效孝宗,提示用戶重新輸入
INFileResolutionResult *result = [INFileResolutionResult needsValue];
completion(result);
}
}
/*!
@abstract Constants indicating the state of the response.
*/
typedef NS_ENUM(NSInteger, SearchQuestionIntentResponseCode) {
SearchQuestionIntentResponseCodeUnspecified = 0,
SearchQuestionIntentResponseCodeReady,
SearchQuestionIntentResponseCodeContinueInApp,
SearchQuestionIntentResponseCodeInProgress,
SearchQuestionIntentResponseCodeSuccess,
SearchQuestionIntentResponseCodeFailure,
SearchQuestionIntentResponseCodeFailureRequiringAppLaunch
} API_AVAILABLE(ios(12.0), macos(11.0), watchos(5.0)) API_UNAVAILABLE(tvos);
2.3 自定義浮窗UI
接下來我們需要修改IntentsUI的代碼穷躁,來修改我們Siri浮窗的展示。系統(tǒng)對Siri浮窗的UI限制較多因妇,我們只被允許修改中間的部分內(nèi)容问潭。這部分內(nèi)容由IntentViewController提供。加載流程如下圖
我們實現(xiàn)configureViewForParameters:ofInteraction:interactiveBehavior:context:completion:
來定制我們的ui婚被,參考intentsUI文檔狡忙。對于不同的Intent,我們應該實現(xiàn)不同的ViewController址芯,并經(jīng)其添加到self.view當中灾茁。
需要注意的是,受蘋果限制谷炸,我們添加到siri浮窗中的自定義視圖不支持觸摸事件北专,因為無法實現(xiàn)點擊按鈕、滑動等效果旬陡。但是當用戶點擊自定義視圖的整體部分時逗余,系統(tǒng)會跳轉到我們的app內(nèi)部。
@implementation IntentViewController
- (void)viewDidLoad {
[super viewDidLoad];
// Do any additional setup after loading the view.
UIView *view = self.view;
while (view) {
view.backgroundColor = [UIColor clearColor];
view = view.superview;
}
}
#pragma mark - INUIHostedViewControlling
// Prepare your view controller for the interaction to handle.
- (void)configureViewForParameters:(NSSet <INParameter *> *)parameters ofInteraction:(INInteraction *)interaction interactiveBehavior:(INUIInteractiveBehavior)interactiveBehavior context:(INUIHostedViewContext)context completion:(void (^)(BOOL success, NSSet <INParameter *> *configuredParameters, CGSize desiredSize))completion {
// Do configuration here, including preparing views and calculating a desired size for presentation.
CGSize desiredSize = [self extensionContext].hostedViewMaximumAllowedSize;
if ([interaction.intent isKindOfClass:[SearchQuestionIntent class]]) {
SearchQuestionResultViewController *searchResultVC = [[SearchQuestionResultViewController alloc] initWithIntent:(SearchQuestionIntent *)interaction.intent completion:^(BOOL success, CGFloat contentHeight) {
CGSize size = CGSizeMake(desiredSize.width, MIN(desiredSize.height, contentHeight));
if (completion) {
completion(success, parameters, size);
}
}];
[self addChildViewController:searchResultVC];
[self.view addSubview:searchResultVC.view];
searchResultVC.view.frame = CGRectMake(0, 0, desiredSize.width, desiredSize.height);
} else {
if (completion) {
completion(YES, parameters, desiredSize);
}
}
}
- (CGSize)desiredSize {
CGSize size = [self extensionContext].hostedViewMaximumAllowedSize;
return size;
}
@end
2.4 數(shù)據(jù)同步
主app是作為 application 運行季惩,intents和intentsUI則是作為 plugin 運行录粱,它們有不同的沙盒和進程。如果需要在它們之間進行數(shù)據(jù)共享画拾,比如同步cookie啥繁、用戶配置等,可以采用Keychain Group 或者 App Group青抛,我們采用的是Keychain Group方案旗闽。
app的沙盒在Containers/Data/Application下,intents的沙盒在 Containers/Data/PluginKitPlugin下蜜另, appGroup的沙盒在 Containers/Shared/AppGroup下适室,Keychain Group則是加密存儲在系統(tǒng)的keychain中
2.4.1 添加Keychain Group
在主app的target下,Signing & Capability -> + Capability -> Keychain Sharing, 添加新的Keychain group举瑰,比如 com.fenbi.share.searchDemo.share捣辆。同樣地,在intents此迅、intentsUI下添加相同的 Keychain Group 汽畴。Xcode會自動生成權限聲明文件(.entitlements)旧巾,其中 appIdentifierPrefix 是我們的apple開發(fā)團隊id,他作為前綴拼接在前面忍些,最終的 Keychain Group 是 {開發(fā)團隊id}.com.fenbi.share.searchDemo.share鲁猩。我們可以在 Build Settings->Signing->Code Signing Entitlements 修改Debug、Release各自對應的權限聲明文件罢坝。
2.4.2 使用Security進行鑰匙串讀寫
#import <Security/Security.h>
#define kKeychainGroup @"com.fenbi.share.searchDemo.share"
#define kKeychainUserService @"com.fenbi.share.searchDemo.userservice"
+ (NSString *)appIdentifierPrefix {
#pragma mark - todo 這是Demo隨機生成的id廓握,實際開發(fā)中需要替換成開發(fā)團隊id
return @"W4E5KLUTS8";
}
+ (NSString *)groupName {
return [NSString stringWithFormat:@"%@.%@", [self appIdentifierPrefix], kKeychainGroup];
}
// 保存key-value到keychain
+ (BOOL)saveData:(nullable NSData *)data key:(NSString *)key {
if (key == nil) {
return NO;
}
NSMutableDictionary *query = @{
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: kKeychainUserService,
(__bridge id)kSecAttrAccessGroup: [self groupName],
(__bridge id)kSecAttrAccount: key,
}.mutableCopy;
// 先嘗試刪除數(shù)據(jù)
SecItemDelete((__bridge CFDictionaryRef)query);
if (data) {
query[(__bridge id)kSecValueData] = data;
OSStatus status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
if (status != errSecSuccess) {
return NO;
}
}
return YES;
}
// 從keychain讀取value
+ (NSData *)getDataForkey:(NSString *)key {
if (key == nil) {
return nil;
}
NSDictionary *query = @{
(__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: kKeychainUserService,
(__bridge id)kSecAttrAccount: key,
(__bridge id)kSecAttrAccessGroup: [self groupName],
(__bridge id)kSecReturnData : @YES,
};
CFTypeRef result = NULL;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
if (status != errSecSuccess) {
return nil;
}
NSData *data = (__bridge_transfer NSData *)result;
return data;
}
2.5 調(diào)試
如果需要調(diào)試Intents或者IntentsUI,我們需要選中對應的target(比如SearchIntentUI)嘁酿,點擊build后在 Choose an app to run 彈窗中選擇Shortcuts隙券。
2.6 打包
主app、Intents痹仙、IntentsUI都需要各自的bundle identifier是尔,需要在apple developer后臺添加對應的Identifier殉了,并使用同一個簽名證書生成各自的Profiles开仰。
在build或者打包的時候,需要讓主app和Extension的 Signing & Capability下 Signing Certificate 保持一致薪铜,同時 Build Settings 下的 Architectures 的配置也應保持一致众弓。
否則當主app和Extension引入相同的第三方framework時,就會由于主app和Extentsion的簽名證書或架構類型的不同而導致簽名失敗隔箍。
例如報錯:Embedded binary is not signed with the same certificate as the parent app. Verify the embedded binary target's code sign settings match the parent app's.
Intents 和 IntentsUI 是主app target的工程依賴谓娃,打包時以app擴展的形式嵌入到主程序包中的。我們可以在主app target的 Build Phases 下的 Target Dependencies 和 Embed Foundation Extensions 查看蜒滩。打包時它們以Embed Without Signing的方式嵌入的滨达,因為它們自己已經(jīng)進行了簽名,不需要主app再次對它們進行簽名俯艰。
打包后如下圖
3. 生成快捷指令iCloud鏈接
打開 快捷指令app捡遍,在我們的app下能看到所添加 “搜題??”。我們新建一個快捷指令竹握,分別添加“截屏”画株、“搜題??”,如下圖啦辐∥酱可以看到截屏的結果已經(jīng)作為“搜題??”的圖片參數(shù)輸入了∏酃兀“運行時顯示”打開時才能顯示Siri浮窗续挟。我們點擊分享,生成快捷指令的iCloud鏈接侥衬,之后我們就可以通過iCloud鏈接引導用戶快速地構建這個指令庸推。我們生成的鏈接是:https://www.icloud.com/shortcuts/3b76dbdcd840459fa4819a7974b6b08e常侦,用 UIApplication 的openURL:options:completionHandler:
方法打開它。
// NSString *urlString = @"ShortCut://create-shortCut";
NSString *urlString = @"https://www.icloud.com/shortcuts/40e542ab5bdc4a3084dea5e8a0616de4";
NSURL *url = [NSURL URLWithString:urlString];
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
4. 關聯(lián)觸控
打開 設置-輔助功能-觸控-輔助觸控 頁面贬媒,打開輔助觸控功能聋亡,在 自定義操作 中選擇一個手勢,比如長按际乘,選中我們的快捷指令“搜題??”坡倔。之后我們就可以在手機任意頁面,通過長按觸控球來進行搜題了脖含。