Mobile Development 27 min read

iOS 17 Widget Extension Development Practices and Solutions

The Kuwo Music team shares practical iOS 17 widget‑extension lessons, detailing container‑background adaptation, borderless button styling, AppIntent communication and hidden shortcuts, Link‑based navigation, lyric animation, refresh‑rate throttling, and splitting WidgetBundles to bypass the ten‑widget limit, all with ready‑to‑copy SwiftUI code.

Tencent Music Tech Team
Tencent Music Tech Team
Tencent Music Tech Team
iOS 17 Widget Extension Development Practices and Solutions

This article, contributed by the Kuwo Music team of TME (Tencent Music Entertainment), records the practical experience of developing iOS 17 widget extensions. It is not a tutorial copied from WWDC, but a collection of real‑world problems encountered during deep development of widget features.

Background : With iOS 17 becoming widespread, many apps need to extend functionality to the home‑screen widget area. Kuwo Music, as part of the TME ecosystem, explores the new widget capabilities introduced in iOS 17.

Problems Encountered

iOS 17 container view adaptation issues.

SwiftUI Button not working correctly inside a widget.

AppIntent‑based button communication between the widget extension and the host app.

Making custom intents invisible in the Shortcuts app.

widgetURL and Link navigation conflicts.

Lyric animation implementation.

Refresh rate limits.

WidgetBundle exceeding the 10‑widget limit.

Deciding when to launch the host app (“拉端”) from a widget.

Pre‑development Glossary

The main app is referred to as host app .

The widget itself is called widget (extension target).

Creating a new target under the same bundle is the standard way to add a widget.

Development uses SwiftUI; Objective‑C compatibility is not covered.

Opening the host app from a widget is called 拉端 (launch‑app).

Deep links are used for URL‑based navigation.

Key Technical Solutions

1. Container Background Adaptation (iOS 17)

Use the new API to set the container background:

KWTestLargeWidgetView(entry: entry)
    .containerBackground(.fill.tertiary, for: .widget)

Wrap the code with availability checks at the bundle entry point to avoid affecting iOS 16 and earlier:

import WidgetKit
import SwiftUI

@available(iOSApplicationExtension 16.0, *)
@main
struct WidgetLauncher {
    static func main() {
        if #available(iOSApplicationExtension 17.0, *) {
            WidgetsBundle17.main()
        } else {
            WidgetsBundle16.main()
        }
    }
}

struct WidgetsBundle16: WidgetBundle { var body: some Widget { KWxxxxWidget1() /* … */ } }

@available(iOSApplicationExtension 17.0, *)
struct WidgetsBundle17: WidgetBundle { var body: some Widget { KWxxxxWidget1() /* … */ } }

Disable the default safe‑area margins when needed:

var body: some WidgetConfiguration {
    AppIntentConfiguration(kind: kind, intent: KWAppWidgetTestConfigurationIntent.self, provider: Provider()) { entry in
        KWTestLargeWidgetView(entry: entry)
            .containerBackground(.fill.tertiary, for: .widget)
    }
    .contentMarginsDisabled()
    .configurationDisplayName("复古磁带机")
    .description("让音乐以经典形态呈现")
}

2. Button Styling in Widgets

Standard Button adds unwanted padding. Apply a borderless style:

Button(action: {
    // action
}, label: {
    Image("123")
        .frame(minWidth: 90, minHeight: 90)
        .aspectRatio(contentMode: .fill)
        .tint(.clear)
        .padding(0)
})
.buttonStyle(BorderlessButtonStyle())

Only AppIntent ‑backed buttons or Toggle are supported in widgets.

3. AppIntent Communication

Define an intent that performs the desired work in the host app:

struct KWAppWidgetConfigurationLikeIntent: AudioPlaybackIntent {
    static var title: LocalizedStringResource = "收藏或取消收藏当前歌曲"
    static var description = IntentDescription("酷我音乐")
    var info: KWWidgetItemInfo
    init(info: KWWidgetItemInfo = .defaultInfo()) { self.info = info }
    func perform() async throws -> some IntentResult {
        try await KWWidgetAppIntentHandle.handleWidgetAppIntentFavorite()
        return .result()
    }
}

The same intent can be duplicated in both the host app target and the widget extension target; the system extracts static metadata, allowing the widget to invoke host‑app logic without sharing runtime code.

4. Hiding Intents from Shortcuts

struct KWAppWidgetConfigurationIntent: WidgetConfigurationIntent {
    static var title: LocalizedStringResource = "酷我音乐"
    static var description = IntentDescription("酷我音乐小组件.")
    static var openAppWhenRun: Bool = true
    static var isDiscoverable: Bool = false // hide from Shortcuts
    func perform() async throws -> some IntentResult { .result() }
}

5. Navigation with Link Instead of Multiple widgetURL

Only one widgetURL is allowed; using Link for each interactive view resolves the undefined behavior:

HStack(alignment: .bottom) {
    Link(destination: URL(string: "sunyazhou://collectOrNot")!) {
        Image(itemInfo!.didCollected ? "kw_widget_absorption_color_like" : "kw_widget_absorption_color_unlike")
            .resizable()
            .aspectRatio(contentMode: .fit)
            .frame(minWidth: itemSize.width, maxWidth: .infinity, minHeight: itemSize.height, maxHeight: .infinity)
            .border(.red)
    }
    Link(destination: URL(string: "sunyazhou://playOrPause")!) { /* … */ }
    Link(destination: URL(string: "sunyazhou://playNext")!) { /* … */ }
}

6. Lyric Animation

Text(entry.info.currLine)
    .font(.system(size: 20).bold())
    .foregroundColor(.white)
    .lineLimit(2)
    .truncationMode(.tail)
    .id(entry.info.currLine)
    .transition(.push(from: .bottom))
    .animation(.easeInOut(duration: 0.6), value: entry.info.currLine)

7. Refresh Rate Limiting

Widgets cannot be refreshed arbitrarily. Use a delayed reload to stay within system limits:

@objc class func reloadAllSystemWidgets() {
    NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(delayReloadAllSystemWidgets), object: nil)
    self.perform(#selector(delayReloadAllSystemWidgets), with: nil, afterDelay: 0.2)
}

@objc private class func delayReloadAllSystemWidgets() {
    WidgetCenter.shared.reloadTimelines(ofKind: "com.sunyazhou.widget1")
    // … other kinds
}

8. Exceeding the 10‑Widget Limit

Split widgets into multiple WidgetBundle groups and combine them with WidgetBundleBuilder.buildBlock (max 10 per bundle):

@available(iOS 17.0, *)
struct WidgetsBundleA: WidgetBundle {
    var body: some Widget {
        KWPlayMusicWidget1()
        KWPlayMusicWidget2()
        // … up to 10
    }
}

@main
struct KWWidgetBundle: WidgetBundle {
    var body: some Widget {
        if #available(iOS 17.0, *) {
            WidgetBundleBuilder.buildBlock(WidgetsBundleA().body, WidgetsBundleB().body)
        }
    }
}

Conclusion

The article lists the most common pitfalls when building iOS 17 widgets—container adaptation, button handling, intent visibility, navigation, animation, refresh throttling, and bundle limits—and provides concrete Swift/SwiftUI code snippets that can be directly copied into a project.

iOSextensionSwiftUIAppIntentiOS17WidgetKit
Tencent Music Tech Team
Written by

Tencent Music Tech Team

Public account of Tencent Music's development team, focusing on technology sharing and communication.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.