返回博客首页
← 所有文章

在 iOS 应用中添加主屏幕小部件

2023 年 12 月 12 日 — 作者 Jason Cassidy

在本文中,我们将探讨如何为您的 NativeScript iOS 应用开发主屏幕小部件。如果您按照本文中的步骤操作,您将构建一个看起来像这样的简单小部件:widget-home-screen-widget.png

在开始之前,需要注意以下几点

  • iOS 扩展必须使用 Xcode 使用原生代码构建。由于 Xcode 只能在 macOS 上运行,因此以下步骤只能在 Mac 上执行。
  • 扩展需要它们自己的应用程序标识符和它们自己的配置文件,这里采用的方法是将 .widget 附加到您的应用程序标识符。

设置我们的测试项目

让我们创建一个示例项目(将 com.nativescript.ExtensionApp 替换为您的应用程序标识符)

ns create ExtensionApp --template @nativescript/template-hello-world-ts --appid com.nativescript.ExtensionApp
cd ExtensionApp
ns prepare ios

创建小部件

打开 Xcode,并打开我们在 ExtensionApp/platforms/ios/ExtensionApp.xcodeproj 中创建的项目,注意:较大的应用程序将有一个 .xcworkspace,在这种情况下,您应该打开它。

打开 文件新建目标.. 菜单项。

对于 iOS 平台,选择 小部件扩展,然后点击下一步。 widget-select-widget.png

  • 输入 widget 作为项目名称,并取消选中 包含实时活动包含配置应用程序意图
  • 点击完成。
  • 点击激活以激活方案。

该项目现在已经填充了一些示例小部件代码。

将小部件代码移动到您的 NativeScript 项目中

创建一个文件夹 App_Resources/iOS/extensions

将文件夹 platforms/ios/widget 复制到 App_Resources/iOS/extensions 中,这样您现在将拥有一个文件夹 App_Resources/iOS/extensions/widget

创建一个文件 App_Resources/iOS/extensions/widget/extension.json

添加内容

{
    "frameworks": ["SwiftUI.framework", "WidgetKit.framework"],
    "targetBuildConfigurationProperties": {
        "ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME" : "AccentColor",
        "ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME" : "WidgetBackground",
        "CLANG_ANALYZER_NONNULL" : "YES",
        "CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION" : "YES_AGGRESSIVE",
        "CLANG_CXX_LANGUAGE_STANDARD" : "\"gnu++20\"",
        "CLANG_ENABLE_OBJC_WEAK" : "YES",
        "CLANG_WARN_DOCUMENTATION_COMMENTS" : "YES",
        "CLANG_WARN_UNGUARDED_AVAILABILITY" : "YES_AGGRESSIVE",
        
        "CURRENT_PROJECT_VERSION" : 1,
        "GCC_C_LANGUAGE_STANDARD" : "gnu11",
        "GCC_WARN_UNINITIALIZED_AUTOS" : "YES_AGGRESSIVE",
        "GENERATE_INFOPLIST_FILE": "YES",        
    
        "INFOPLIST_KEY_CFBundleDisplayName" : "widget",

        "INFOPLIST_KEY_NSHumanReadableCopyright" : "\"Copyright © 2023 NativeScript. All rights reserved.\"",
        "IPHONEOS_DEPLOYMENT_TARGET" : 16.4,
        
        "MARKETING_VERSION" : "1.0",

        "MTL_FAST_MATH" : "YES",
        
        "PRODUCT_NAME" : "widget",        
        
        "SWIFT_EMIT_LOC_STRINGS" : "YES",
        
        "SWIFT_VERSION" : "5.0",
        "TARGETED_DEVICE_FAMILY" : "\"1,2\"",

        "MTL_ENABLE_DEBUG_INFO" : "NO",
        "SWIFT_OPTIMIZATION_LEVEL" : "\"-O\"",
        "COPY_PHASE_STRIP": "NO",
        "SWIFT_COMPILATION_MODE": "wholemodule"
    },

    "targetNamedBuildConfigurationProperties": {
        "debug": {
            "DEBUG_INFORMATION_FORMAT" : "dwarf",
            "GCC_PREPROCESSOR_DEFINITIONS": "(\"DEBUG=1\",\"$(inherited)\",)",
            "MTL_ENABLE_DEBUG_INFO" : "INCLUDE_SOURCE",
            "SWIFT_ACTIVE_COMPILATION_CONDITIONS" : "DEBUG",
            "SWIFT_OPTIMIZATION_LEVEL" : "\"-Onone\""
        },
        "release": {
            "CODE_SIGN_STYLE" : "Manual",
            "MTL_ENABLE_DEBUG_INFO" : "NO",
            "SWIFT_OPTIMIZATION_LEVEL" : "\"-O\"",
            "COPY_PHASE_STRIP": "NO",
            "SWIFT_COMPILATION_MODE": "wholemodule"
        }
    }
}

注意:这些值取自平台/ios pbxproject 文件的扩展目标,不同的扩展类型可能具有不同的值。

使用 ns run ios 运行应用程序

您现在可以将创建的主屏幕小部件添加到主屏幕。

为发布签名您的应用程序

如上所述,扩展需要它们自己的应用程序标识符和配置文件,因此要正确地为分发签名您的应用程序

创建两个应用程序标识符

  • 一个用于应用程序,例如 org.nativescript.ExtensionApp
  • 一个用于扩展,按照惯例,它是扩展的名称(widget)附加到主机应用程序标识符,例如 org.nativescript.ExtensionApp.widget

然后创建两个相应的配置文件。

在 Xcode 中下载配置文件。

创建 provisioning.json 文件

创建一个新文件 App_Resources/iOS/extensions/provisioning.json,其内容为

{
    "com.nativescript.ExtensionApp.widget": "<The name or UUID of the widget provisioning profile>"
}

将小部件应用程序标识符和配置文件 UUID/名称替换为您自己的。

构建发布版 ipa

现在,您可以使用以下命令构建应用程序的发布版本以供发布

ns build ios --release --for-device --provision [Main App Profile Name/UUID] --env.production

处理权利

为了演示如何处理权利,我们将共享应用程序和扩展之间的数据。为此,我们将使用 应用程序组

创建一个应用程序组,并为其分配 group.<您的应用程序 ID> 的 ID,例如 group.org.nativescript.ExtensionApp

将该应用程序组添加到您的主应用程序和扩展应用程序 ID 配置中(如果通过 Apple 开发者网站进行操作,请记住重新生成配置文件)。

创建一个新文件 App_Resources/iOS/extensions/widget/widget.entitlements,并添加

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.application-groups</key>
	<array>
		<string><!-- your group id --></string>
	</array>
</dict>
</plist>

将相同的内容添加到 App_Resources/iOS/app.entitlements

App_Resources/iOS/extensions/widget/extension.jsontargetBuildConfigurationProperties 部分添加

"CODE_SIGN_ENTITLEMENTS": "../../App_Resources/iOS/extensions/widget/widget.entitlements",

应用程序和小部件现在都应该能够访问应用程序组中的项目。

修改应用程序以将值传递给小部件

我们将使用 UserDefaults 在应用程序和小部件之间传递数据。

修改文件 app/main-view-model.ts,并将以下内容添加到 HelloWorldModel 类中

  private getGroupId(): string {    
        return "group." + NSBundle.mainBundle.bundleIdentifier;   
  }
  private readonly VALUE_KEY="COUNTER";
  private updateSharedValue(){
      const defaults=NSUserDefaults.alloc().initWithSuiteName(this.getGroupId());
      defaults.setObjectForKey(this._counter.toString(), this.VALUE_KEY); 
  }

HelloWorldModel 类的 onTap() 方法的末尾添加对 this.updateSharedValue(); 的调用。

修改小部件以读取从应用程序传递的值

修改小部件代码以从 UserDefaults 读取,方法是将 App_Resources/iOS/extensions/widget/widget.swift 的内容替换为

import WidgetKit
import SwiftUI

struct Provider: TimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), emoji: "😀")
    }

    func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
        let entry = SimpleEntry(date: Date(), emoji: "😀")
        completion(entry)
    }
    private func getGroupId() -> String {
        var result: String = ""
        if let bundleIdentifier = Bundle.main.bundleIdentifier {
            let replacedString = bundleIdentifier
                .replacingOccurrences(of: ".widget", with: "")
            result = "group." + replacedString
        }
        return result;
    }
    
    func getTimeline(in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
        var entries: [SimpleEntry] = [];
        var count = UserDefaults(suiteName: getGroupId())!.string(forKey: "COUNTER");
        // Generate a timeline consisting of five entries an hour apart, starting from the current date.
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, emoji: "😀", count: count ?? "")
            
            entries.append(entry)
        }

        let timeline = Timeline(entries: entries, policy: .atEnd)
        completion(timeline)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let emoji: String
    var count: String = ""
}

struct widgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {
        VStack {
            Text("Time:")
            Text(entry.date, style: .time)

            Text("Emoji:")
            Text(entry.emoji + " " + entry.count)
            
        }
    }
}

struct widget: Widget {
    let kind: String = "widget"

    var body: some WidgetConfiguration {
        StaticConfiguration(kind: kind, provider: Provider()) { entry in
            if #available(iOS 17.0, *) {
                widgetEntryView(entry: entry)
                    .containerBackground(.fill.tertiary, for: .widget)
            } else {
                widgetEntryView(entry: entry)
                    .padding()
                    .background()
            }
        }
        .configurationDisplayName("My Widget")
        .description("This is an example widget.")
    }
}

#Preview(as: .systemSmall) {
    widget()
} timeline: {
    SimpleEntry(date: .now, emoji: "😀")
    SimpleEntry(date: .now, emoji: "🤩")
}

从应用程序触发小部件的更新

有了上面的代码,小部件将定期使用应用程序中点击事件的最新结果进行更新,但我们更希望小部件立即更新。

要实现这一点,我们需要调用 WidgetKit 的相关方法来触发更新。WidgetKit 默认情况下无法从 TypeScript 访问,因此我们必须创建一个 swift 实用类。

创建文件 App_Resources/iOS/src/utility.swift,其内容为

import Foundation
import WidgetKit

@objcMembers
@objc(NSCUtilsHelper)
public class NSCUtilsHelper: NSObject {
    
    public static func updateWidget(){
        if #available(iOS 14.0, *) {
            Task.detached(priority: .userInitiated) {
                WidgetCenter.shared.reloadAllTimelines()
            }
        }         
    }

}

创建文件 types/objc!nsswiftsupport.d.ts,其内容为


declare class NSCUtilsHelper extends NSObject {

	static alloc(): NSCUtilsHelper; // inherited from NSObject

	static new(): NSCUtilsHelper; // inherited from NSObject

	static updateWidget(): void;
}

将以下行添加到 references.d.ts

/// <reference path="./types/objc!nsswiftsupport.d.ts" />

最后,将以下行添加到 app/main-view-model.ts 中的 updateSharedValue 方法中

NSCUtilsHelper.updateWidget();

现在,当您点击 点击 按钮时,小部件将反映应用程序中显示的计数器。

一些说明

  • 如果传递给小部件的应用程序数据是机密数据,请使用钥匙串来共享数据。
  • 发布新版本时,小部件的 CURRENT_PROJECT_VERSIONMARKETING_VERSION 需要与父应用程序版本匹配。