iOS快捷指令實現(xiàn)浮窗搜題

本文介紹如何使用快捷指令結合觸控功能薄腻,實現(xiàn)在不打開app的情況下板惑,通過長按屏幕彈出搜題浮窗。

1. 添加Intent

我們需要在工程內(nèi)引入Intents Extension蛀缝、Intents UI Extension。其中Intents Extension用于處理快捷指令阎毅,Intents UI Extension用于設置快捷指令觸發(fā)后的浮窗UI焚刚。

添加Extension

添加Intent Definition File,這是個定義文件净薛,我們可以在其中添加我們app支持的快捷指令。

Intent Definition File

我們把intent.intentdefinition的app蒲拉、Intents肃拜、IntentsUI的target都勾選上,以便它們都能調(diào)用我們的自定義Intent類雌团。

勾選target

添加新的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
配置Intent 1
配置intent 2

添加完Intent后,需要修改主app略板、Intents毁枯、IntentsUI的info.plist, 添加對SearchQuestionIntent的關聯(lián)

主app info.plist
Intents info.plist
IntentsUI info.plist
?? 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

編譯報錯
方法定義
Xcode14 note

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 -> handlehandle 方法返回了一個帶有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提供。加載流程如下圖

IntentsUI加載流程

我們實現(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各自對應的權限聲明文件罢坝。

entitlements文件

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隙券。

Choose an app to run.png

2.6 打包

主app、Intents痹仙、IntentsUI都需要各自的bundle identifier是尔,需要在apple developer后臺添加對應的Identifier殉了,并使用同一個簽名證書生成各自的Profiles开仰。
在build或者打包的時候,需要讓主app和Extension的 Signing & CapabilitySigning Certificate 保持一致薪铜,同時 Build Settings 下的 Architectures 的配置也應保持一致众弓。

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 DependenciesEmbed Foundation Extensions 查看蜒滩。打包時它們以Embed Without Signing的方式嵌入的滨达,因為它們自己已經(jīng)進行了簽名,不需要主app再次對它們進行簽名俯艰。

Build Phases

打包后如下圖


ipa包

3. 生成快捷指令iCloud鏈接

打開 快捷指令app捡遍,在我們的app下能看到所添加 “搜題??”。我們新建一個快捷指令竹握,分別添加“截屏”画株、“搜題??”,如下圖啦辐∥酱可以看到截屏的結果已經(jīng)作為“搜題??”的圖片參數(shù)輸入了∏酃兀“運行時顯示”打開時才能顯示Siri浮窗续挟。我們點擊分享,生成快捷指令的iCloud鏈接侥衬,之后我們就可以通過iCloud鏈接引導用戶快速地構建這個指令庸推。我們生成的鏈接是:https://www.icloud.com/shortcuts/3b76dbdcd840459fa4819a7974b6b08e常侦,用 UIApplicationopenURL: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)觸控

打開 設置-輔助功能-觸控-輔助觸控 頁面贬媒,打開輔助觸控功能聋亡,在 自定義操作 中選擇一個手勢,比如長按际乘,選中我們的快捷指令“搜題??”坡倔。之后我們就可以在手機任意頁面,通過長按觸控球來進行搜題了脖含。

效果展示

5. Demo

demo地址:https://github.com/linjunyi/SearchApp

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末罪塔,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子养葵,更是在濱河造成了極大的恐慌征堪,老刑警劉巖,帶你破解...
    沈念sama閱讀 222,807評論 6 518
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件关拒,死亡現(xiàn)場離奇詭異佃蚜,居然都是意外死亡,警方通過查閱死者的電腦和手機着绊,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 95,284評論 3 399
  • 文/潘曉璐 我一進店門谐算,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人归露,你說我怎么就攤上這事洲脂。” “怎么了剧包?”我有些...
    開封第一講書人閱讀 169,589評論 0 363
  • 文/不壞的土叔 我叫張陵恐锦,是天一觀的道長。 經(jīng)常有香客問我疆液,道長一铅,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 60,188評論 1 300
  • 正文 為了忘掉前任枚粘,我火速辦了婚禮馅闽,結果婚禮上,老公的妹妹穿的比我還像新娘馍迄。我一直安慰自己福也,他們只是感情好,可當我...
    茶點故事閱讀 69,185評論 6 398
  • 文/花漫 我一把揭開白布攀圈。 她就那樣靜靜地躺著暴凑,像睡著了一般。 火紅的嫁衣襯著肌膚如雪赘来。 梳的紋絲不亂的頭發(fā)上现喳,一...
    開封第一講書人閱讀 52,785評論 1 314
  • 那天凯傲,我揣著相機與錄音,去河邊找鬼嗦篱。 笑死冰单,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的灸促。 我是一名探鬼主播诫欠,決...
    沈念sama閱讀 41,220評論 3 423
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼浴栽!你這毒婦竟也來了荒叼?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 40,167評論 0 277
  • 序言:老撾萬榮一對情侶失蹤典鸡,失蹤者是張志新(化名)和其女友劉穎被廓,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體萝玷,經(jīng)...
    沈念sama閱讀 46,698評論 1 320
  • 正文 獨居荒郊野嶺守林人離奇死亡嫁乘,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,767評論 3 343
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了间护。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片亦渗。...
    茶點故事閱讀 40,912評論 1 353
  • 序言:一個原本活蹦亂跳的男人離奇死亡挖诸,死狀恐怖汁尺,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情多律,我是刑警寧澤痴突,帶...
    沈念sama閱讀 36,572評論 5 351
  • 正文 年R本政府宣布,位于F島的核電站狼荞,受9級特大地震影響辽装,放射性物質發(fā)生泄漏。R本人自食惡果不足惜相味,卻給世界環(huán)境...
    茶點故事閱讀 42,254評論 3 336
  • 文/蒙蒙 一拾积、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧丰涉,春花似錦拓巧、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,746評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至投慈,卻和暖如春承耿,著一層夾襖步出監(jiān)牢的瞬間冠骄,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,859評論 1 274
  • 我被黑心中介騙來泰國打工加袋, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留凛辣,地道東北人。 一個月前我還...
    沈念sama閱讀 49,359評論 3 379
  • 正文 我出身青樓职烧,卻偏偏與公主長得像蟀给,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子阳堕,可洞房花燭夜當晚...
    茶點故事閱讀 45,922評論 2 361

推薦閱讀更多精彩內(nèi)容