fix(native): give home-screen widgets a container background and full-bleed artwork

iOS 17+ rendered 'Please adopt containerBackground API' because expo-widgets 56 dropped its hard-coded container background and neither widget applied the modifier. Artwork also drew at its raw pixel size because the images were never resizable, and the layout assumed a fixed 155pt box with default content margins.

Apply containerBackground, make the artwork resizable, aspect-filled and clipped, replace the flat overlay with a gradient scrim, left-align the text block, use a native linear ProgressView, and disable content margins via the plugin's ios config. Also correct the Continue Watching description, since the feed only contains TV episodes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Sonnet 5.5 committed 2026-10-04 17:37:08 -04:00
1 parent 6a1b753a49
commit 9bbe36b2d4
3 files changed
+118 -60

No files matched your search

+9 -3
View File
@@ -152,14 +152,20 @@
{ {
"name": "ContinueWatching", "name": "ContinueWatching",
"displayName": "Continue Watching", "displayName": "Continue Watching",
"description": "Your in-progress TV shows and movies", "description": "The next episode of the shows you're watching",
"supportedFamilies": ["systemSmall"] "ios": {
"supportedFamilies": ["systemSmall"],
"contentMarginsDisabled": true
}
}, },
{ {
"name": "Upcoming", "name": "Upcoming",
"displayName": "Upcoming", "displayName": "Upcoming",
"description": "Upcoming episodes and movie releases", "description": "Upcoming episodes and movie releases",
"supportedFamilies": ["systemSmall"] "ios": {
"supportedFamilies": ["systemSmall"],
"contentMarginsDisabled": true
}
} }
] ]
} }
+58 -36
View File
@@ -1,12 +1,18 @@
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import { HStack, Image, ProgressView, Rectangle, Text, VStack, ZStack } from "@expo/ui/swift-ui";
import { import {
background, aspectRatio,
clipped,
containerBackground,
cornerRadius, cornerRadius,
font, font,
foregroundStyle, foregroundStyle,
frame, frame,
lineLimit, lineLimit,
padding, padding,
progressViewStyle,
resizable,
tint,
widgetAccentedRenderingMode,
widgetURL, widgetURL,
} from "@expo/ui/swift-ui/modifiers"; } from "@expo/ui/swift-ui/modifiers";
import { createWidget, type WidgetEnvironment } from "expo-widgets"; import { createWidget, type WidgetEnvironment } from "expo-widgets";
@@ -26,19 +32,17 @@ export type ContinueWatchingProps = {
const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => { const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnvironment) => {
"widget"; "widget";
// This function is serialized and run inside the widget extension's own JS runtime, so
// everything it uses must be defined in here.
const fill = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity };
if (!props.titleName) { if (!props.titleName) {
return ( return (
<VStack <VStack spacing={8} modifiers={[frame(fill), containerBackground("#101010", "widget")]}>
modifiers={[
frame({ width: 155, height: 155 }),
background("#101010"),
padding({ all: 16 }),
]}
>
{props.iconFilePath ? ( {props.iconFilePath ? (
<Image <Image
uiImage={props.iconFilePath} uiImage={props.iconFilePath}
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]} modifiers={[resizable(), frame({ width: 40, height: 40 }), cornerRadius(8)]}
/> />
) : ( ) : (
<Image systemName="play.tv" color="#FFFFFF" size={40} /> <Image systemName="play.tv" color="#FFFFFF" size={40} />
@@ -55,7 +59,10 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
); );
} }
const progress = props.totalEpisodes > 0 ? props.watchedEpisodes / props.totalEpisodes : 0; const progress = Math.min(
1,
Math.max(0, props.totalEpisodes > 0 ? props.watchedEpisodes / props.totalEpisodes : 0),
);
const episodeLabel = const episodeLabel =
!props.isMovie && props.seasonNumber != null !props.isMovie && props.seasonNumber != null
? `S${props.seasonNumber} · E${props.episodeNumber}` ? `S${props.seasonNumber} · E${props.episodeNumber}`
@@ -68,23 +75,43 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
return ( return (
<ZStack <ZStack
alignment="bottomLeading" alignment="bottomLeading"
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]} modifiers={[
frame(fill),
containerBackground("#101010", "widget"),
widgetURL(`sofa://title/${props.titleId}`),
]}
> >
{/* Background image or dark fallback */} {/* Full-bleed artwork; the container background shows through when there is none */}
{props.imageFilePath ? ( {props.imageFilePath ? (
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} /> <Image
) : ( uiImage={props.imageFilePath}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} /> modifiers={[
)} resizable(),
widgetAccentedRenderingMode("desaturated"),
aspectRatio({ contentMode: "fill" }),
frame(fill),
clipped(),
]}
/>
) : null}
{/* Dark overlay for text readability */} {/* Gradient scrim for text readability */}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} /> <Rectangle
modifiers={[
foregroundStyle({
type: "linearGradient",
colors: ["rgba(0,0,0,0)", "rgba(0,0,0,0.85)"],
startPoint: { x: 0.5, y: 0.2 },
endPoint: { x: 0.5, y: 1 },
}),
]}
/>
{/* Text overlay at bottom */} {/* Text overlay at bottom */}
<VStack <VStack
alignment="leading" alignment="leading"
spacing={2} spacing={2}
modifiers={[padding({ all: 12 }), frame({ width: 155 })]} modifiers={[frame({ maxWidth: Infinity, alignment: "leading" }), padding({ all: 14 })]}
> >
{episodeLabel && ( {episodeLabel && (
<HStack spacing={4}> <HStack spacing={4}>
@@ -92,12 +119,19 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
modifiers={[ modifiers={[
font({ weight: "medium", size: 11 }), font({ weight: "medium", size: 11 }),
foregroundStyle("rgba(255,255,255,0.7)"), foregroundStyle("rgba(255,255,255,0.7)"),
lineLimit(1),
]} ]}
> >
{episodeLabel} {episodeLabel}
</Text> </Text>
{progressLabel && ( {progressLabel && (
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}> <Text
modifiers={[
font({ size: 11 }),
foregroundStyle("rgba(255,255,255,0.5)"),
lineLimit(1),
]}
>
{progressLabel} {progressLabel}
</Text> </Text>
)} )}
@@ -110,22 +144,10 @@ const ContinueWatchingWidget = (props: ContinueWatchingProps, _env: WidgetEnviro
</Text> </Text>
{/* Progress bar for TV shows */} {/* Progress bar for TV shows */}
{!props.isMovie && props.totalEpisodes > 0 && ( {!props.isMovie && props.totalEpisodes > 0 && (
<ZStack modifiers={[frame({ width: 131, height: 3 }), padding({ top: 2 })]}> <ProgressView
<Spacer value={progress}
modifiers={[ modifiers={[progressViewStyle("linear"), tint("#3b82f6"), padding({ top: 4 })]}
frame({ width: 131, height: 3 }),
cornerRadius(1.5),
background("rgba(255,255,255,0.15)"),
]}
/> />
<Spacer
modifiers={[
frame({ width: Math.max(3, Math.round(131 * progress)), height: 3 }),
cornerRadius(1.5),
background("#3b82f6"),
]}
/>
</ZStack>
)} )}
</VStack> </VStack>
</ZStack> </ZStack>
+51 -21
View File
@@ -1,12 +1,16 @@
import { HStack, Image, Spacer, Text, VStack, ZStack } from "@expo/ui/swift-ui"; import { HStack, Image, Rectangle, Text, VStack, ZStack } from "@expo/ui/swift-ui";
import { import {
background, aspectRatio,
clipped,
containerBackground,
cornerRadius, cornerRadius,
font, font,
foregroundStyle, foregroundStyle,
frame, frame,
lineLimit, lineLimit,
padding, padding,
resizable,
widgetAccentedRenderingMode,
widgetURL, widgetURL,
} from "@expo/ui/swift-ui/modifiers"; } from "@expo/ui/swift-ui/modifiers";
import { createWidget, type WidgetEnvironment } from "expo-widgets"; import { createWidget, type WidgetEnvironment } from "expo-widgets";
@@ -27,19 +31,17 @@ export type UpcomingProps = {
const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => { const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
"widget"; "widget";
// This function is serialized and run inside the widget extension's own JS runtime, so
// everything it uses must be defined in here.
const fill = { minWidth: 0, maxWidth: Infinity, minHeight: 0, maxHeight: Infinity };
if (!props.titleName) { if (!props.titleName) {
return ( return (
<VStack <VStack spacing={8} modifiers={[frame(fill), containerBackground("#101010", "widget")]}>
modifiers={[
frame({ width: 155, height: 155 }),
background("#101010"),
padding({ all: 16 }),
]}
>
{props.iconFilePath ? ( {props.iconFilePath ? (
<Image <Image
uiImage={props.iconFilePath} uiImage={props.iconFilePath}
modifiers={[frame({ width: 40, height: 40 }), cornerRadius(8)]} modifiers={[resizable(), frame({ width: 40, height: 40 }), cornerRadius(8)]}
/> />
) : ( ) : (
<Image systemName="play.tv" color="#FFFFFF" size={40} /> <Image systemName="play.tv" color="#FFFFFF" size={40} />
@@ -59,29 +61,57 @@ const UpcomingWidget = (props: UpcomingProps, _env: WidgetEnvironment) => {
return ( return (
<ZStack <ZStack
alignment="bottomLeading" alignment="bottomLeading"
modifiers={[frame({ width: 155, height: 155 }), widgetURL(`sofa://title/${props.titleId}`)]} modifiers={[
frame(fill),
containerBackground("#101010", "widget"),
widgetURL(`sofa://title/${props.titleId}`),
]}
> >
{/* Background image or dark fallback */} {/* Full-bleed artwork; the container background shows through when there is none */}
{props.imageFilePath ? ( {props.imageFilePath ? (
<Image uiImage={props.imageFilePath} modifiers={[frame({ width: 155, height: 155 })]} /> <Image
) : ( uiImage={props.imageFilePath}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("#1a1a1a")]} /> modifiers={[
)} resizable(),
widgetAccentedRenderingMode("desaturated"),
aspectRatio({ contentMode: "fill" }),
frame(fill),
clipped(),
]}
/>
) : null}
{/* Dark overlay for text readability */} {/* Gradient scrim for text readability */}
<Spacer modifiers={[frame({ width: 155, height: 155 }), background("rgba(0,0,0,0.45)")]} /> <Rectangle
modifiers={[
foregroundStyle({
type: "linearGradient",
colors: ["rgba(0,0,0,0)", "rgba(0,0,0,0.85)"],
startPoint: { x: 0.5, y: 0.2 },
endPoint: { x: 0.5, y: 1 },
}),
]}
/>
{/* Text overlay at bottom */} {/* Text overlay at bottom */}
<VStack <VStack
alignment="leading" alignment="leading"
spacing={2} spacing={2}
modifiers={[padding({ all: 12 }), frame({ width: 155 })]} modifiers={[frame({ maxWidth: Infinity, alignment: "leading" }), padding({ all: 14 })]}
> >
<HStack spacing={4}> <HStack spacing={4}>
<Text modifiers={[font({ weight: "medium", size: 11 }), foregroundStyle("#e4a532")]}> <Text
modifiers={[
font({ weight: "medium", size: 11 }),
foregroundStyle("#e4a532"),
lineLimit(1),
]}
>
{props.dateLabel} {props.dateLabel}
</Text> </Text>
<Text modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)")]}> <Text
modifiers={[font({ size: 11 }), foregroundStyle("rgba(255,255,255,0.5)"), lineLimit(1)]}
>
· {props.episodeLabel} · {props.episodeLabel}
</Text> </Text>
</HStack> </HStack>