[@expo/ui][expo-widgets] Text: support iOS 18's live date formats (SystemFormatStyle) so a Live Activity can count down in hours and minutes without seconds
Nick Lamont
@expo/ui
's SwiftUI Text
can show a self-updating date only through timerInterval
(Text(timerInterval:countsDown:)
) or date
+ dateStyle
(.timer
, .relative
, .offset
). In a Live Activity rendered by expo-widgets
, every one of these either shows seconds or carries a sign. Measured 42 m 30 s before a date, read 1 to 2 minutes later (unlocked in Notification Center / on the locked Lock Screen):- timerInterval+countsDown:40:39/41:--
- dateStyle="timer":40:38/41 minutes
- dateStyle="relative":40 min, 38 sec(compact Dynamic Island:40 min, 49…) /41 min
- dateStyle="offset":−40 minutes/−41 minutes
- ProgressViewtimerInterval(its value label):40:39/41:--
So once the phone is unlocked (Face ID on the Lock Screen, Notification Center, the Dynamic Island), every option shows seconds except
offset
, which always carries a minus sign and offers no control over the sign or the fields.iOS 18 added live-updating system formats that WidgetKit and ActivityKit render without timeline reloads or pushes:
Text(_ source: TimeDataSource<Date>, format: some DiscreteFormatStyle)
with SystemFormatStyle.DateOffset
, .DateReference
, .Timer
and .Stopwatch
. Apple introduced them at WWDC24 ("What's new in SwiftUI") for widgets and Live Activities. With allowedFields: [.hour, .minute]
they never show seconds, for example 2 hr, 15 min
or 42 min
. With .textVariant(.sizeDependent)
they also narrow to 2h 15m
/ 42m
in a compact Dynamic Island.Neither
@expo/ui
58.0.28 nor expo-widgets
58.0.28 exposes them. And because expo-widgets
renders a closed set of @expo/ui
views in the extension (ios/Widgets/ViewRenderer.swift
), an app can't add its own SwiftUI view to fill the gap.The same gap shows on
ProgressView(timerInterval:countsDown:)
. @expo/ui
uses the label:
initializer, which shows SwiftUI's default current-value label (42:30
, with seconds), and there's no prop to hide it.Repro
An SDK 58 app with
@expo/ui@58.0.28
and expo-widgets@58.0.28
(no patches), and this Live Activity:```tsx
import { HStack, Image, ProgressView, Text, VStack } from '@expo/ui/swift-ui';
import { createLiveActivity } from 'expo-widgets';
const Countdown = createLiveActivity<{ fromMs: number; atMs: number }>('Countdown', (props) => {
'widget';
const from = new Date(props.fromMs);
const at = new Date(props.atMs);
return {
banner: (
<VStack alignment="leading">
<HStack><Text>timerInterval</Text><Text timerInterval={{ lower: from, upper: at }} countsDown /></HStack>
<HStack><Text>dateStyle timer</Text><Text date={at} dateStyle="timer" /></HStack>
<HStack><Text>dateStyle relative</Text><Text date={at} dateStyle="relative" /></HStack>
<HStack><Text>dateStyle offset</Text><Text date={at} dateStyle="offset" /></HStack>
<ProgressView timerInterval={{ lower: from, upper: at }} countsDown />
</VStack>
),
compactLeading: <Image systemName="timer" />,
compactTrailing: <Text date={at} dateStyle="relative" />,
minimal: <Image systemName="timer" />,
};
});
const fromMs = Date.now();
Countdown.start({ fromMs, atMs: fromMs + 42.5 * 60_000 });
Look at the Lock Screen while locked, then unlocked (Notification Center), and at the compact Dynamic Island from the Home Screen. You get the readings above. There's no way, through either package, to read `42 min` (or `2h 15m`) and keep counting without a push per minute.
**Proposed API**
On `Text` (iOS 18+; below 18 it falls back to the existing `date` / `dateStyle` / `timerInterval` props, so a layout can pass both):
```ts
type TextLiveFormat =
| { kind: 'offset'; to: Date; allowedFields?: ('hour' | 'minute' | 'second')[]; sign?: 'automatic' | 'never' | 'always' }
| { kind: 'reference'; to: Date; allowedFields?: ('day' | 'hour' | 'minute' | 'second')[]; maxFieldCount?: number }
| { kind: 'timer'; countingDownIn: { lower: Date; upper: Date }; maxPrecisionSeconds?: number };
<Text liveFormat={{ kind: 'offset', to: start, allowedFields: ['hour', 'minute'], sign: 'never' }} />
Plus a
textVariant
modifier ('sizeDependent' | 'preferredFormat'
), mapping to .textVariant(_:)
.On
ProgressView
: hidesCurrentValueLabel?: boolean
, mapping to init(timerInterval:countsDown:label:currentValueLabel: { EmptyView() })
.Swift side, in
TextView.swift
's buildText
:```swift
if #available(iOS 18.0, *), let live = props.liveFormat {
switch live.kind {
case "offset":
return Text(.currentDate, format: .offset(to: live.to, allowedFields: live.fields, sign: live.sign))
case "timer":
return Text(.currentDate, format: .timer(countingDownIn: live.lower..<live.upper, maxPrecision: .seconds(live.precision)))
// reference, ...
}
}
```
One detail from doing it:
.textVariant(_:)
is declared on Text
and returns some View
, so it has to be applied to the Text
before the view-modifier array (which erases to a View), while buildText
stays a Text
for nested-Text concatenation.We carry the
offset
arm (with sizeDependent
) and the hidden ProgressView label as a patch-package patch today, across ios/TextView.swift
, ios/ProgressView.swift
and the two TS components. We'd gladly open the PR if the shape is welcome.Why
Event and travel apps show "starts in" countdowns on the lock screen, where seconds are noise. On a locked iPhone the system replaces them with dashes (
42:--
), which reads as broken. Apple's own guidance for Live Activities is to update rarely and let the system tick. The iOS 18 formats are exactly that, and today they're unreachable from Expo.Environment
Expo SDK 58 (expo 58.0.22),
@expo/ui
58.0.28, expo-widgets
58.0.28 (both unpatched), Xcode 27.1, iPhone 17 simulator on iOS 27.