【iOS小组件】可配置小组件

可配置小组件

注意:

  • AppIntentConfigurationWidgetConfigurationIntent 需要 iOS17 及以后版本
  • iOS17 之前需要使用 SiriKit 意图定义文件和 Intents Extension

在创建自定义iOS小组件时,如果你希望用户在添加小组件时能够自定义参数,你需要使用 App Intents 框架(iOS16 引入)来实现动态的配置。

这通常是在 WidgetConfigurationIntent (iOS17引入)的基础上,定义自定义的参数类型和选项,允许用户在配置小组件时提供自定义数据。

比如日历组件,当你长按小组件时,可以选择编辑小组件按钮:

image.png

添加配置意图

iOS17 系统及以上版本,可以通过实现 WidgetConfigurationIntent 协议来完成小组件自定义配置,这种方式是纯代码实现,不需要添加意图定义文件,也不需要添加意图扩展。

1.创建项目时配置

如果需要添加类似的配置,需要在创建小组件的时候把 Include Configuration Intent 勾选上。

image.png

创建完后会多出一个 AppIntent 文件,这就是用于配置的文件:

image.png

2.手动添加

创建一个 AppIntent.swift 文件,按照如下改写

image.png

import WidgetKit
import AppIntents

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    
    @Parameter(title: "Favorite Emoji", default: "😃")
    var favoriteEmoji: String
}
import WidgetKit
import SwiftUI

struct Provider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
    }

    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: configuration)
    }

    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []

        
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, configuration: configuration)
            entries.append(entry)
        }

        return Timeline(entries: entries, policy: .atEnd)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationAppIntent
}

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

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

            Text("Favorite Emoji:")
            Text(entry.configuration.favoriteEmoji)
        }
    }
}

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

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
    }
}

extension ConfigurationAppIntent {
    fileprivate static var smiley: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "😀"
        return intent
    }

    fileprivate static var starEyes: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "🤩"
        return intent
    }
}


#Preview(as: .systemSmall) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, configuration: .smiley)
    SimpleEntry(date: .now, configuration: .starEyes)
}

配置

1.文本类型

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    @Parameter(title: "Favorite Emoji", default: "😃")
    var favoriteEmoji: String
}

2.枚举类型

enum TemperatureUnit: String, CaseIterable, AppEnum {
    case celsius = "Celsius"
    case fahrenheit = "Fahrenheit"

    
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Temperature Unit"

    
    static var caseDisplayRepresentations: [TemperatureUnit : DisplayRepresentation] = [
        .celsius: "Celsius (°C)",
        .fahrenheit: "Fahrenheit (°F)"
    ]
}

enum WidgetBackgroundColor: String, CaseIterable, AppEnum {
    case blue = "Blue"
    case green = "Green"
    case orange = "Orange"

    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Background Color"
    static var caseDisplayRepresentations: [WidgetBackgroundColor : DisplayRepresentation] = [
        .blue: "Blue",
        .green: "Green",
        .orange: "Orange"
    ]

    var color: Color {
        switch self {
            case .blue: return .blue
            case .green: return .green
            case .orange: return .orange
        }
    }
}

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    @Parameter(title: "Temperature Unit", default: TemperatureUnit.celsius)
    var temperatureUnit: TemperatureUnit

    @Parameter(title: "Background Color", default: WidgetBackgroundColor.blue)
    var backgroundColor: WidgetBackgroundColor
}

3.Bool类型

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    @Parameter(title: "Show Humidity", default: false)
    var showHumidity: Bool
}

4.AppEntity

struct WeatherLocation: AppEntity {
    let id: String
    let name: String
    let latitude: Double
    let longitude: Double

    
    static var typeDisplayRepresentation: TypeDisplayRepresentation = "Weather Location"

    
    var displayRepresentation: DisplayRepresentation {
        DisplayRepresentation(title: "(name)")
    }

    
    static var defaultQuery = WeatherLocationQuery()
}

struct WeatherLocationQuery: EntityQuery {
    
    func entities(for identifiers: [WeatherLocation.ID]) async throws -> [WeatherLocation] {
        return identifiers.compactMap { id in
            switch id {
            case "london": return WeatherLocation(id: "london", name: "London", latitude: 51.5074, longitude: -0.1278)
            case "new-york": return WeatherLocation(id: "new-york", name: "New York", latitude: 40.7128, longitude: -74.0060)
            case "tokyo": return WeatherLocation(id: "tokyo", name: "Tokyo", latitude: 35.6762, longitude: 139.6503)
            default: return nil
            }
        }
    }

    
    func suggestedEntities() async throws -> [WeatherLocation] {
        return [
            WeatherLocation(id: "london", name: "London", latitude: 51.5074, longitude: -0.1278),
            WeatherLocation(id: "new-york", name: "New York", latitude: 40.7128, longitude: -74.0060),
            WeatherLocation(id: "tokyo", name: "Tokyo", latitude: 35.6762, longitude: 139.6503)
        ]
    }
}

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    @Parameter(title: "Location")
    var location: WeatherLocation?
}

5.ParameterSummary(参数摘要)

struct ConfigurationAppIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "Configuration"
    static var description = IntentDescription("This is an example widget.")

    @Parameter(title: "Favorite Emoji", default: "😃")
    var favoriteEmoji: String

    @Parameter(title: "Temperature Unit", default: TemperatureUnit.celsius)
    var temperatureUnit: TemperatureUnit

    @Parameter(title: "Location")
    var location: WeatherLocation?

    
    static var parameterSummary: some ParameterSummary {
       Summary("Show weather for (.$location) in (.$temperatureUnit)")
    }
}

使用

import WidgetKit
import SwiftUI

struct Provider: AppIntentTimelineProvider {
    func placeholder(in context: Context) -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: ConfigurationAppIntent())
    }

    func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> SimpleEntry {
        SimpleEntry(date: Date(), configuration: configuration)
    }

    func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<SimpleEntry> {
        var entries: [SimpleEntry] = []

        
        let currentDate = Date()
        for hourOffset in 0 ..< 5 {
            let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)!
            let entry = SimpleEntry(date: entryDate, configuration: configuration)
            entries.append(entry)
        }

        return Timeline(entries: entries, policy: .atEnd)
    }
}

struct SimpleEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigurationAppIntent
}

struct MyWidgetEntryView : View {
    var entry: Provider.Entry

    var body: some View {

        VStack {
            Text(entry.configuration.location?.name ?? "Select Location")
                .font(.headline)
            if let location = entry.configuration.location {
                Text(formattedTemperature())
                    .font(.title2)
                Text(formattedCoordinates(location))
                    .font(.caption)
            } else {
                Text("No location selected")
                    .font(.caption)
            }
        }
    }

    func formattedTemperature() -> String {
       let temperature = 25.0 
       switch entry.configuration.temperatureUnit {
       case .celsius:
           return String(format: "%.1f°C", temperature)
       case .fahrenheit:
           let fahrenheit = temperature * 9/5 + 32
           return String(format: "%.1f°F", fahrenheit)
       }
   }

   func formattedCoordinates(_ location: WeatherLocation) -> String {
       return String(format: "Lat: %.2f, Lon: %.2f", location.latitude, location.longitude)
   }
}

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

    var body: some WidgetConfiguration {
        AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: Provider()) { entry in
            MyWidgetEntryView(entry: entry)
                .containerBackground(.fill.tertiary, for: .widget)
        }
        .supportedFamilies([.systemMedium])
    }
}

extension ConfigurationAppIntent {
    fileprivate static var smiley: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "😀"
        return intent
    }

    fileprivate static var starEyes: ConfigurationAppIntent {
        let intent = ConfigurationAppIntent()
        intent.favoriteEmoji = "🤩"
        return intent
    }
}


#Preview(as: .systemSmall) {
    MyWidget()
} timeline: {
    SimpleEntry(date: .now, configuration: .smiley)
    SimpleEntry(date: .now, configuration: .starEyes)
}

效果

image.png

低版本兼容(iOS17以下)

iOS17系统以下版本,通过创建 SiriKit 意图定义文件和意图扩展程序,来实现小组件自定义配置。

1.依赖intentdefinition file

将 WeatherWidget.intentdefinition 依赖到项目(从原文下载,这里不支持上传),使用默认的或者配置新的

2.配置Intent

注意

配置Intent时,名称不要带Intent, 系统在生成配置文件时会自动加上Intent,

如ConfigureWeather最终生成的为ConfigureWeatherIntent

配置新Intent: 选中文件 -> New Intent 创建一个新Intent

image.png

image.png

实现组件

import WidgetKit
import SwiftUI


extension View {
    
    @ViewBuilder
    func widgetBackground(_ backgroundView: some View) -> some View {
        if #available(iOS 17.0, *) {
            containerBackground(for: .widget) {
                backgroundView
            }
        } else {
            background(backgroundView)
        }
    }
}

extension WidgetConfiguration {
    
    func adoptableWidgetContentMargin() -> some WidgetConfiguration {
        if #available(iOSApplicationExtension 15.0, *) {
            print("15.0 以上系统")
            return contentMarginsDisabled()
        } else {
            print("15.0 以下系统")
            return self
        }
    }
}


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

    var body: some WidgetConfiguration {
        IntentConfiguration(kind: kind, intent: ConfigureWeatherIntent.self, provider: WeatherProvider()) { entry in
            WeatherWidgetEntryView(entry: entry)
        }
        .configurationDisplayName("Weather Widget")
        .description("Display weather information for a selected location.")
        .supportedFamilies([.systemSmall, .systemMedium])
        .adoptableWidgetContentMargin()
    }
}


struct WeatherWidgetEntryView: View {
    var entry: WeatherProvider.Entry

    var body: some View {
        VStack {
            Text(entry.configuration.location ?? "Select Location")
                .font(.headline)
            Text(formattedTemperature(entry.configuration))
                .font(.title2)
        }
        .widgetBackground(Color.white)
    }
    
    func formattedTemperature(_ configuration: ConfigureWeatherIntent) -> String {
        return String(format: "%.1f°F", configuration.temperatureUnit ?? 0.0)
    }
}


struct WeatherProvider: IntentTimelineProvider {
    func placeholder(in context: Context) -> WeatherEntry {
        WeatherEntry(date: Date(), configuration: ConfigureWeatherIntent())
    }

    func getSnapshot(for configuration: ConfigureWeatherIntent, in context: Context, completion: @escaping (WeatherEntry) -> ()) {
        let entry = WeatherEntry(date: Date(), configuration: configuration)
        completion(entry)
    }

    func getTimeline(for configuration: ConfigureWeatherIntent, in context: Context, completion: @escaping (Timeline<WeatherEntry>) -> ()) {
        let entry = WeatherEntry(date: Date(), configuration: configuration)
        let timeline = Timeline(entries: [entry], policy: .atEnd)
        completion(timeline)
    }
}


struct WeatherEntry: TimelineEntry {
    let date: Date
    let configuration: ConfigureWeatherIntent
}


struct WeatherWidget_Previews: PreviewProvider {
    static var previews: some View {
        WeatherWidgetEntryView(entry: WeatherEntry(date: Date(), configuration: ConfigureWeatherIntent()))
            .previewContext(WidgetPreviewContext(family: .systemSmall))
    }
}

参考链接

developer.apple.com/cn/document…

本文同步自微信公众号 “程序员小溪” ,这里只是同步,想看及时消息请移步我的公众号,不定时更新我的学习经验。

阅读全文
下载说明:
1、本站所有资源均从互联网上收集整理而来,仅供学习交流之用,因此不包含技术服务请大家谅解!
2、本站不提供任何实质性的付费和支付资源,所有需要积分下载的资源均为网站运营赞助费用或者线下劳务费用!
3、本站所有资源仅用于学习及研究使用,您必须在下载后的24小时内删除所下载资源,切勿用于商业用途,否则由此引发的法律纠纷及连带责任本站和发布者概不承担!
4、本站站内提供的所有可下载资源,本站保证未做任何负面改动(不包含修复bug和完善功能等正面优化或二次开发),但本站不保证资源的准确性、安全性和完整性,用户下载后自行斟酌,我们以交流学习为目的,并不是所有的源码都100%无错或无bug!如有链接无法下载、失效或广告,请联系客服处理!
5、本站资源除标明原创外均来自网络整理,版权归原作者或本站特约原创作者所有,如侵犯到您的合法权益,请立即告知本站,本站将及时予与删除并致以最深的歉意!
6、如果您也有好的资源或教程,您可以投稿发布,成功分享后有站币奖励和额外收入!
7、如果您喜欢该资源,请支持官方正版资源,以得到更好的正版服务!
8、请您认真阅读上述内容,注册本站用户或下载本站资源即您同意上述内容!
原文链接:https://www.shuli.cc/?p=20948,转载请注明出处。
0

评论0

显示验证码
没有账号?注册  忘记密码?