当你更新了 Xcode 15,如果你的 app 中有小组件的代码,在 preview 的页面就会出现上图的预览错误。提示你:Widget needs to adopt container background。虽然这种 breaking change 看起来很吓人,但是适配起来还是很容易的,下面将一一列出 iOS17 小组件有关的 适配方案。
containerBackground
iOS 17 中新增了一个模式 stand by。这个模式下手机横屏,可以并排显示两个小号的小组件。因为手机此时属于息屏状态,因此苹果建议小组件的背景图层隐藏,这样整体的风格也更搭。
同时锁屏小组件也带到了iPadOS 17 上。相比 iPhone 的锁屏小组件,iPad 因为尺寸更大,因此锁屏小组件的尺寸也支持了一个更大的尺寸。可以显示小号的方形小组件。
苹果为了强推小组件这两个功能,要求所有小组件都必须声明适配接口告知系统小组件的背景图层。这样当小组件显示在 standby 和 iPad 锁屏上时,渲染时可以隐藏背景图层。
如果你的 app 只需要支持 iOS 17(不会真有人这么幸福吧),那么你只需要在 view 实现这个 containerBackground 就可以了,把背景图层像 background 一样放在 content 闭包里。
.containerBackground(for: .widget) {
// 背景view
Color.black
}
但是做 iOS 的开发者运气都不会太差,你大概率会得到一个 error:
所以你需要自定义一个类似的方法,判断系统版本以向前兼容:
extension View {
@ViewBuilder
func widgetBackground(_ backgroundView: some View) -> some View {
if #available(iOS 17.0, *) {
containerBackground(for: .widget) {
backgroundView
}
} else {
background(backgroundView)
}
}
}
如果你的小组件view不在 app 中展示,那么上述的方法已经足够用了。但是如果你的小组件要在 app 中展示,比如我目前的情况,小组件会在 app 中展示以让用户进行一些主题设置。那么你就会发现 containerBackground 的背景 view 在 app 中不会展示。
因此需要再加一层判断,如果在 app 中正常显示背景图层。
extension View {
@ViewBuilder
func widgetBackground(_ backgroundView: some View) -> some View {
if Bundle.main.bundlePath.hasSuffix(".appex"){
if #available(iOS 17.0, *) {
containerBackground(for: .widget) {
backgroundView
}
} else {
background(backgroundView)
}
} else {
background(backgroundView)
}
}
}
contentMarginsDisabled
在配置完 containerBackground 后小组件可以正常运行了,但是很快你就发现一个问题:小组件尺寸变小了。比如下图里黑色是小组件的背景色,外围的一圈是 safeArea。
原因和上一节讲的一样,因为小号小组件会出现在 standby 中,然而 standby 的尺寸更大。因此为了让小组件可以适配不同的尺寸,系统统一给小组件加了一个 safeArea。因此我们的小组件变小了。
如果你的小组件可以针对尺寸大小自适应的话,或者不在乎 standby 中的样式,可以直接在 WidgetConfiguration 中配置关闭系统统一发放的边距。需要注意的是这个配置在 widget 上,不在 view 上。
StaticConfiguration(kind: WorkerWidgetKind.workerSticker.rawValue,
provider: WorkerStickerProvider()) { entry in
WorkerStickerEntryView(entry: entry)
}
.contentMarginsDisabled()
如果你打算针对不同的 margin 处理布局,你也可以通过全局变量获取到 margin 值。
@Environment(\.widgetContentMargins) var margins
extension EnvironmentValues {
/// A property that identifies the content margins of a widget.
///
/// The content margins of a widget depend on the context in which it appears. The
/// system applies default content margins. However, if you disable automatic application of
/// default content margins with ``WidgetConfiguration/contentMarginsDisabled()``, the
/// system uses the `widgetContentMargins` property in combination with ``View/padding(_)``
/// to selectively apply default content margins.
///
@available(iOS 17.0, watchOS 10.0, macOS 14.0, *)
@available(tvOS, unavailable)
public var widgetContentMargins: EdgeInsets { get }
}
但是用这个值会有点痛苦,因为苹果常规操作这个全局变量 iOS 17 only。View 相关全局变量的如果要向前兼容需要包在一个 container view 里,有些小麻烦。
showsWidgetContainerBackground
如果你的小组件某些 UI 要针对在无背景场景做调整,需要通过 showsWidgetContainerBackground 全局变量来判断。
以我的小组件周五日历为例,本来有背景中间的标题文字视觉就是居中的。但是如果没有背景,标题文字的视觉平衡就不在中间了。而且我的标题文字本来有一个透明度,但是在锁屏上因为没有背景了,有透明度反而让文字看不清了。
因此我需要针对在锁屏上做一点区分处理。如果在锁屏上就在背景上画一个边框。
struct FridayWidgetView: View {
@Environment(\.showsWidgetContainerBackground) var showsWidgetContainerBackground
var body: some View {
ZStack {
if !showsWidgetContainerBackground {
RoundedRectangle(cornerRadius: 12)
.stroke(Color.black, lineWidth: 3)
}
}
.widgetBackground(viewModel.config.theme.coverView)
}
}
特大喜讯,苹果工程师良心发现这个全局变量可以向前兼容。
下图是适配以后的样式。
containerBackgroundRemovable
小号的方形小组件可以在展示在锁屏上又引入了另外一个问题,锁屏中系统会对图片进行黑白处理,某些小组件的核心内容是图片的话不适合展示在图片上。
下面的示例图是我开发的打工人小组件,可以看到显示在 iPad 锁屏上显示效果差到无法用。
为了解决这个问题,需要在小组件配置中声明containerBackgroundRemovable(false)
。
struct WeekCalendarWidget: Widget {
var body: some WidgetConfiguration {
IntentConfiguration(kind: WorkerWidgetKind.weekCalendar.rawValue,
intent: WeekCalendarIntent.self,
provider: WeekCalendarTimelineProvider()) { entry in
WeekCalendarEntryView(entry: entry)
}
.configurationDisplayName("打工人周历")
.description("熬夜可以,熬夜工作可不行")
.containerBackgroundRemovable(false)
.contentMarginsDisabled()
}
}
配置了这个选项后小组件就不会出现在 iPad 锁屏小组件列表中。
widgetRenderingMode
如果要针对图片在不同场景中做单独处理,也可以通过 widgetRenderingMode 这个全局变量判断当前的渲染模式。坏消息:兼容性iOS 16 +。
@available(iOS 16.0, watchOS 9.0, macOS 13.0, *)
@available(tvOS, unavailable)
extension EnvironmentValues {
/// The widget's rendering mode, based on where the system is displaying it.
///
/// You can read the rendering mode from the environment values using this
/// key.
///
/// ``` swift
/// @Environment(\.widgetRenderingMode) var widgetRenderingMode
/// ```
///
/// Then modify the widget's appearance based on the mode.
///
/// ``` swift
/// var body: some View {
/// ZStack {
/// switch renderingMode {
/// case .fullColor:
/// Text("Full color")
/// case .accented:
/// ZStack {
/// Circle(...)
/// VStack {
/// Text("Accented")
/// .widgetAccentable()
/// Text("Normal")
/// }
/// }
/// case .vibrant:
/// Text("Full color")
/// default:
/// ...
/// }
/// }
/// }
/// ```
public var widgetRenderingMode: WidgetRenderingMode
}