Apple widgets: Home Screen, lock screen, Dynamic Island
Widgets and Live Activities run in a separate extension process with no JS runtime — everything is SwiftUI on the native side, fed by your NativeScript app through an App Group. Official guide: https://docs.nativescript.org/guide/widgets-ios.md
1. Scaffold — ns widget ios
NativeScript CLI 8.9+ generates the whole stack with one command (prompts for a name and a type: Live Activity, Home Screen Widget, or both): ns widget ios.
| Path | Role |
|---|---|
App_Resources/iOS/extensions/<name>/ |
The widget extension target: <Name>Bundle.swift (@main WidgetBundle), the SwiftUI files, Info.plist (NSExtensionPointIdentifier: com.apple.widgetkit-extension), <name>.entitlements, extension.json, PrivacyInfo.xcprivacy |
App_Resources/iOS/extensions/provisioning.json |
Maps the extension bundle id (<appId>.<name>) → provisioning profile UUID |
App_Resources/iOS/src/AppleWidgetUtils.swift |
The JS ↔ native bridge (§3) |
Shared_Resources/iOS/SharedWidget/ |
Local Swift package shared by app + extension — holds the ActivityAttributes model (the data contract) |
App_Resources/iOS/app.entitlements |
App Groups capability (created if missing) |
Non-negotiable prerequisites (each fails silently or with a misleading error when missing):
IPHONEOS_DEPLOYMENT_TARGET = 17.0;inApp_Resources/iOS/build.xcconfig— ActivityKit and the widgetcontainerBackgroundAPI need it; without it you get "only available in application extensions for iOS 17.0 or newer" build errors.NSSupportsLiveActivities→truein the app'sInfo.plist— without itActivity.requestthrows and no island/lock-screen UI ever appears.- The same App Group id (
group.<appId>) in bothapp.entitlementsand the extension's.entitlements. A mismatch = widget reads nil forever, no error anywhere. - Device builds: each extension needs its own provisioning profile — find the Xcode-managed one under
~/Library/Developer/Xcode/UserData/Provisioning Profiles, put its UUID inprovisioning.json.
The shared package is wired in nativescript.config.ts — targets: ['widget'] links it into the extension in addition to the app:
ios: {
SPMPackages: [
{ name: 'SharedWidget', libs: ['SharedWidget'],
path: './Shared_Resources/iOS/SharedWidget', targets: ['widget'] }
]
}
2. The data contract: preformatted strings through the App Group
The app and widget share JSON strings in App Group UserDefaults — JS stringifies a payload, the widget decodes it with Codable. Two rules: format every label on the JS side (the widget never does timezone math or date formatting — ship display-ready strings like timeLabel: "11:00 AM" plus raw epoch ms for the only math the widget legitimately does: filtering by "now"; SwiftUI views stay dumb renderers). And keep the shared key constants in sync by comment on both sides — the TS key constant and the Swift key enum must match by string; a drift is another silent-nil failure. Same for the App Group id, which appears in four places (2 entitlements files, the bridge, the widget).
3. The JS bridge: @objcMembers static utils
WidgetKit and ActivityKit are Swift-only — the bridge is a small @objcMembers public class AppleWidgetUtils: NSObject of static methods in App_Resources/iOS/src/ (app-target sources are auto-compiled; the class is visible to JS with a bare declare const AppleWidgetUtils: any, or typed declarations in references.d.ts).
- JS object literals arrive as
NSDictionary— unpack defensively:data.object(forKey: "title") as? String ?? "". WidgetCenter.shared.reloadAllTimelines()after every App Group write, from a detached Task.- Keep every WidgetKit/ActivityKit call off the launch path — cold-start native calls in field initializers/constructors cause white-screen/boot jank; defer with a
setTimeout(..., 2000).
4. Live Activity + Dynamic Island specifics
ActivityAttributeslives in the shared package: fixed attributes = set once at start;ContentState= everything updatable.- Start only from the foreground — ActivityKit rejects background
requests. Sync on your data-feed updates and onApplication.resumeEvent; that covers both. - One activity, idempotent sync: expose
hasActiveActivity()from the bridge and branch start-vs-update in JS. On start, firstend(nil, dismissalPolicy: .immediate)any existing activities — orphans from a previous run otherwise stack up. - End with meaning: when the session completes,
endwith a final ContentState rather than just cancelling — the lock screen shows the final state briefly. - Dynamic Island regions: use
DynamicIslandExpandedRegion(.center)for a full-width row below the camera —.leading/.trailingclip around the cutout. Provide all three small forms:compactLeading(brand mark18pt),16pt mark). Gate oncompactTrailing(the one live datum),minimal(ActivityAuthorizationInfo().areActivitiesEnabledbefore every call, and wrapActivitycalls inTask { }(they're async) so bridge methods stay synchronous fire-and-forget for JS.
5. Home Screen widget: precompute the timeline, no network
Widget extensions get milliseconds of CPU and no reliable network. Don't fetch — precompute entries from the App Group payload: one entry for "now" plus one at each event boundary (e.g. each meeting's end time), so the rendered list advances the instant an event wraps; a coarse policy: .after(30 min) catches payload changes. Always implement a realistic placeholder(in:) — it renders in the widget gallery. WidgetCenter.shared.getCurrentConfigurations (async) tells you how many widgets are actually installed — write the count into the App Group to drive an in-app "Add the widget" promo card.
6. Deep links back into the app
Put .widgetURL(...) on every surface — the lock-screen view, the DynamicIsland { } result, and home widget views — using the app's custom scheme with a source marker: myapp://items/detail?id=X&source=widget. Keep one enum in the widget that mirrors JS routing, and buffer cold-launch URLs on the JS side (the tap may arrive before services subscribe). For per-row links inside a medium/large widget, use Link(destination:); .widgetURL covers the whole-widget tap.
7. Images in the widget (the blank-logo trap)
The extension cannot see the app's Assets.xcassets — Image("SomeAppAsset") silently renders nothing. Bundle images in the shared Swift package: drop the PNG in Sources/SharedWidget/Resources/, declare resources: [.process("Resources")] on the target, and load by file path, not by name. Three silent failure modes, all hit in practice:
- Named lookup renders nothing. In widget processes, SwiftUI's
Image("name", bundle:)— including viaBundle.module— only consults a compiled asset catalog, and SPM.process()ships loose PNGs, no catalog. Only trace:SwiftUI: No image named 'x' found in asset catalog for ...SharedWidget_SharedWidget.bundlein system logs. Fix:bundle.path(forResource:ofType:)→UIImage(contentsOfFile:)→Image(uiImage:). - Oversized bitmap can blank the whole island. ActivityKit refuses images larger than the presentation and can drop the entire archive —
WidgetRenderer: Archive was nil. LiveActivity will be emptyin system logs, island invisible..resizable().frame(height: 18)does NOT protect you: the full bitmap is what gets archived. Pre-resize the asset to ~largest display size × 3 (e.g.sips -Z 96for a ≤32pt mark). Bundle.moduleis a fatalError when the SPM resource bundle isn't embedded in some build path — it kills the extension process → nil archives → invisible activity. Use a nil-returning bundle finder and fall back to a text glyph: a degraded brand mark beats an invisible activity.
private final class BundleFinder {}
// resource bundle is named <PackageName>_<TargetName>.bundle, at the root of
// whichever bundle links the package (.appex for the extension, .app for the app)
static func loadLogo() -> Image? {
let bundleName = "SharedWidget_SharedWidget.bundle"
let candidates = [
Bundle(for: BundleFinder.self).resourceURL,
Bundle.main.resourceURL,
Bundle(for: BundleFinder.self).bundleURL.deletingLastPathComponent(),
]
for candidate in candidates {
guard let url = candidate?.appendingPathComponent(bundleName),
let bundle = Bundle(url: url),
let path = bundle.path(forResource: "logo", ofType: "png"),
let image = UIImage(contentsOfFile: path) else { continue }
return Image(uiImage: image) // NOT Image("logo", bundle:)
}
return nil
}
Annotate shared-package view helpers @available(iOS 13.0, macOS 10.15, *) and guard UIKit with #if canImport(UIKit) — SourceKit type-checks the package for macOS and reports false errors otherwise.
8. Verifying and diagnosing
- Validate the shared package standalone (plain
swift buildfails — ActivityKit is iOS-only):swift build --sdk $(xcrun --sdk iphonesimulator --show-sdk-path) -Xswiftc -target -Xswiftc arm64-apple-ios17.0-simulator. - SourceKit's
No such module 'SharedWidget'inextensions/*/*.swiftis editor noise — those files only compile inside the generatedplatforms/iosproject. Trustns build ios. - Live Activity never appears → check, in order:
NSSupportsLiveActivities; Settings → app → Live Activities toggle; started from foreground;areActivitiesEnabled; bridge prints in device logs. - Was working, now empty/absent → check whether the app intentionally ended it, then grep system logs for
archive was nil(see §7 image traps). AfatalErroradditionally leaves awidget-*.ipsin~/Library/Logs/DiagnosticReports/; no crash report + nil archives points at the image-size limit. - Widget shows placeholder/stale data → App Group id mismatch, or the JS write happened without
reloadAllTimelines; long-press → remove → re-add the widget to force a fresh timeline. - Dynamic Island needs an island device or iPhone 15/16/17-class simulator; elsewhere the same activity renders on the lock screen only — test both.
xcrun simctl io booted screenshotdoes NOT composite the Dynamic Island layer — capture the Simulator window instead (screencapture -x). - After changing
extension.json, entitlements, or provisioning:ns clean— the generated Xcode project caches target config. - Extension
print()/faults don't appear inlog show— attach stdout withxcrun simctl launch --console-pty booted <bundleId>.
Verified 2026-08: iOS 26 simulator (iPhone 16 Pro), NativeScript CLI 8.9+/9.1, @nativescript/core 9.1.0-alpha.11 — Home Screen widget + Live Activity + Dynamic Island shipping in a production app; every trap above hit and diagnosed first-hand. Device provisioning flow untested on CI.