@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
  • ProgressView
    timerInterval
    (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.