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

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

1. 添加Intent

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

添加Extension

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

Intent Definition File

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

勾選target

添加新的Intent,并配置相應(yīng)的選項(xiàng):

  • Category 設(shè)置Intent類型,不同的類型彈窗的UI和交互略有不同
  • Custom Class 系統(tǒng)默認(rèn)生成的Intent只讀頭文件,包含Intent對(duì)應(yīng)的類和handler需遵循的協(xié)議
  • User confirmation required 會(huì)先彈一個(gè)包含 下一步取消 按鈕的彈窗
  • Intent is user-configurable in the Shortcuts app and Add to Siri 指令是否能在快捷指令app中找到
  • 為Intent添加一個(gè)參數(shù) image ,并將其type選擇為File,F(xiàn)ile Type選擇image
  • 在Shortcuts app中的Input parameter選中 image , 將上一個(gè)指令的輸出,作為 image 參數(shù)輸入
添加新Intent
配置Intent 1
配置intent 2

添加完Intent后,需要修改主app、Intents、IntentsUI的info.plist, 添加對(duì)SearchQuestionIntent的關(guān)聯(lián)

主app info.plist
Intents info.plist
IntentsUI info.plist
?? Swift編譯問題解決方案

當(dāng)使用swift時(shí)添加 image 參數(shù)編譯會(huì)報(bào)錯(cuò)。這是因?yàn)樵?strong>SearchQuestionIntent.swift中兩個(gè)swift方法指向了同一個(gè)objc方法導(dǎo)致的。這個(gè)文件由Xcode自動(dòng)生成的只讀文件,我們沒法修改。這是Xcode 14的bug,不過我使用Xcode 15.3也復(fù)現(xiàn)了。解決的辦法是:升級(jí)Xcode或者添加Extension的時(shí)候選擇Objective-C語言。參考:xcode-14-release-notes

編譯報(bào)錯(cuò)
方法定義
Xcode14 note

2. 代碼實(shí)現(xiàn)要點(diǎn)

現(xiàn)在我們已經(jīng)配置完快捷指令了,之后需要在代碼層面進(jìn)行處理。

2.1 AppDelegate

修改AppDelegate內(nèi)關(guān)于UserActivity的代理方法,這個(gè)是快捷指令打開app時(shí)觸發(fā)(包括指令直接調(diào)起app,以及點(diǎn)擊siri浮窗進(jìn)入等情況)。

- (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];
                    // 點(diǎn)擊浮窗區(qū)域跳轉(zhuǎn)進(jìn)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需要實(shí)現(xiàn) SearchQuestionIntentHandling 的代理方法。我們這里實(shí)現(xiàn)了3個(gè)方法,他們的調(diào)用順序是 comfirm -> resolveImage -> handle 。handle 方法返回了一個(gè)帶有code的response,code是在 SearchQuestionIntent.h 中定義的枚舉,每個(gè)code對(duì)應(yīng)一種狀態(tài),其彈窗和交互也略有不同。比如 ContinueInApp 是不彈窗直接跳轉(zhuǎn)到app,Success 彈出一個(gè)帶有勾號(hào)的彈窗,InProgress 也是彈窗但不帶勾號(hào)。(具體的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)對(duì)Siri浮窗的UI限制較多,我們只被允許修改中間的部分內(nèi)容。這部分內(nèi)容由IntentViewController提供。加載流程如下圖

IntentsUI加載流程

我們實(shí)現(xiàn)configureViewForParameters:ofInteraction:interactiveBehavior:context:completion:來定制我們的ui,參考intentsUI文檔。對(duì)于不同的Intent,我們應(yīng)該實(shí)現(xiàn)不同的ViewController,并經(jīng)其添加到self.view當(dāng)中。

需要注意的是,受蘋果限制,我們添加到siri浮窗中的自定義視圖不支持觸摸事件,因?yàn)闊o法實(shí)現(xiàn)點(diǎn)擊按鈕、滑動(dòng)等效果。但是當(dāng)用戶點(diǎn)擊自定義視圖的整體部分時(shí),系統(tǒng)會(huì)跳轉(zhuǎn)到我們的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 運(yùn)行,intents和intentsUI則是作為 plugin 運(yùn)行,它們有不同的沙盒和進(jìn)程。如果需要在它們之間進(jìn)行數(shù)據(jù)共享,比如同步cookie、用戶配置等,可以采用Keychain Group 或者 App Group,我們采用的是Keychain Group方案。

app的沙盒在Containers/Data/Application下,intents的沙盒在 Containers/Data/PluginKitPlugin下, appGroup的沙盒在 Containers/Shared/AppGroup下,Keychain Group則是加密存儲(chǔ)在系統(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會(huì)自動(dòng)生成權(quán)限聲明文件(.entitlements),其中 appIdentifierPrefix 是我們的apple開發(fā)團(tuán)隊(duì)id,他作為前綴拼接在前面,最終的 Keychain Group{開發(fā)團(tuán)隊(duì)id}.com.fenbi.share.searchDemo.share。我們可以在 Build Settings->Signing->Code Signing Entitlements 修改Debug、Release各自對(duì)應(yīng)的權(quán)限聲明文件。

entitlements文件

2.4.2 使用Security進(jìn)行鑰匙串讀寫

#import <Security/Security.h>

#define kKeychainGroup @"com.fenbi.share.searchDemo.share"
#define kKeychainUserService @"com.fenbi.share.searchDemo.userservice"

+ (NSString *)appIdentifierPrefix {
#pragma mark - todo 這是Demo隨機(jī)生成的id,實(shí)際開發(fā)中需要替換成開發(fā)團(tuán)隊(duì)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,我們需要選中對(duì)應(yīng)的target(比如SearchIntentUI),點(diǎn)擊build后在 Choose an app to run 彈窗中選擇Shortcuts。

Choose an app to run.png

2.6 打包

主app、Intents、IntentsUI都需要各自的bundle identifier,需要在apple developer后臺(tái)添加對(duì)應(yīng)的Identifier,并使用同一個(gè)簽名證書生成各自的Profiles。
在build或者打包的時(shí)候,需要讓主app和Extension的 Signing & CapabilitySigning Certificate 保持一致,同時(shí) Build Settings 下的 Architectures 的配置也應(yīng)保持一致。

Architectures配置示例

否則當(dāng)主app和Extension引入相同的第三方framework時(shí),就會(huì)由于主app和Extentsion的簽名證書或架構(gòu)類型的不同而導(dǎo)致簽名失敗。
例如報(bào)錯(cuò):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的工程依賴,打包時(shí)以app擴(kuò)展的形式嵌入到主程序包中的。我們可以在主app target的 Build Phases 下的 Target DependenciesEmbed Foundation Extensions 查看。打包時(shí)它們以Embed Without Signing的方式嵌入的,因?yàn)樗鼈冏约阂呀?jīng)進(jìn)行了簽名,不需要主app再次對(duì)它們進(jìn)行簽名。

Build Phases

打包后如下圖


ipa包

3. 生成快捷指令iCloud鏈接

打開 快捷指令app,在我們的app下能看到所添加 “搜題??”。我們新建一個(gè)快捷指令,分別添加“截屏”、“搜題??”,如下圖??梢钥吹浇仄恋慕Y(jié)果已經(jīng)作為“搜題??”的圖片參數(shù)輸入了?!?strong>運(yùn)行時(shí)顯示”打開時(shí)才能顯示Siri浮窗。我們點(diǎn)擊分享,生成快捷指令的iCloud鏈接,之后我們就可以通過iCloud鏈接引導(dǎo)用戶快速地構(gòu)建這個(gè)指令。我們生成的鏈接是:https://www.icloud.com/shortcuts/3b76dbdcd840459fa4819a7974b6b08e,用 UIApplicationopenURL:options:completionHandler:方法打開它。

構(gòu)造快捷指令
//    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. 關(guān)聯(lián)觸控

打開 設(shè)置-輔助功能-觸控-輔助觸控 頁面,打開輔助觸控功能,在 自定義操作 中選擇一個(gè)手勢(shì),比如長(zhǎng)按,選中我們的快捷指令“搜題??”。之后我們就可以在手機(jī)任意頁面,通過長(zhǎng)按觸控球來進(jìn)行搜題了。

效果展示

5. Demo

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容