Árbol de decisión — ¿qué capa usar?
1
Empieza siempre por Universal — importa desde
@expo/ui. Una sola implementación funciona en iOS, Android y Web sin splits de plataforma.
2
Si la capa universal no cubre el componente/modificador que necesitas → baja a
@expo/ui/swift-ui (iOS, archivo .ios.tsx) o @expo/ui/jetpack-compose (Android, archivo .android.tsx).
3
Migrando una librería comunitaria existente (ej.
@gorhom/bottom-sheet) → usa los drop-in replacements de @expo/ui/community/<nombre>. Mismo API, distinto import.
9:41
●●● ▲ 100%
FitTrack Pro
Lunes, 16 jun 2026
Hoy
Semana
Mes
Año
8.342
Pasos
487
kcal
5,8km
Dist.
ACTIVIDADES
Running matutino
35 min · 5,2 km
Hecho
Fuerza + core
45 min · Gym
Pendiente
Yoga noche
20 min · Casa
Pendiente
El dashboard usa componentes universales de
@expo/ui: List + ListItem para las actividades, y el SegmentedControl drop-in para el filtro Hoy/Semana/Mes/Año — un solo árbol para iOS y Android.Cada árbol
@expo/ui debe estar envuelto en <Host>. Para tamaño intrínseco usa matchContents; para pantalla completa usa style={{ flex: 1 }}.List / ListItem no son aptos para listas grandes — cada item se procesa en el JS thread. Para datasets grandes, usa LazyColumn de Jetpack Compose (Android) o List de SwiftUI dentro del árbol platform-specific.Universal — Dashboard + Filtro (iOS & Android)
Un solo archivo, sin splits de plataforma
Componentes usados
Host, Column, Row, List, ListItem, Text, Switch, SliderDrop-ins usados
SegmentedControl ← @react-native-segmented-control/segmented-controlscreens/Dashboard.tsx
UNIVERSAL
import { Host, Column, Row, List, ListItem, Text, Switch } from '@expo/ui';
import SegmentedControl from '@expo/ui/community/segmented-control';
import { useState } from 'react';
import { Activity } from '../types';
const Dashboard = ({ activities }: { activities: Activity[] }) => {
const [period, setPeriod] = useState(0);
return (
{/* Host es OBLIGATORIO como raíz de todo árbol @expo/ui */}
<Host style={{ flex: 1 }}>
<Column>
{/* Filtro de periodo — drop-in replacement, mismo API que el paquete original */}
<SegmentedControl
values={['Hoy', 'Semana', 'Mes', 'Año']}
selectedIndex={period}
onChange={(e) => setPeriod(e.nativeEvent.selectedSegmentIndex)}
/>
{/* Métricas */}
<Row>
<MetricCard icon="👟" value="8.342" label="Pasos" />
<MetricCard icon="🔥" value="487" label="kcal" />
<MetricCard icon="📍" value="5,8km" label="Dist." />
</Row>
{/* Lista de actividades — OK para < ~50 items */}
<List>
{activities.map((act) => (
<ListItem
key={act.id}
title={act.name}
subtitle={`${act.duration} min · ${act.location}`}
trailing={() =>
<Switch
value={act.completed}
onValueChange={(v) => toggleActivity(act.id, v)}
/>
}
/>
))}
</List>
</Column>
</Host>
);
};
Drop-in Replacements — Migración FitTrack Pro
Mismo API, nuevo import — cero cambios en props
| Librería actual (eliminar) | Nuevo import @expo/ui | Cambios requeridos |
|---|---|---|
| @gorhom/bottom-sheet | @expo/ui/community/bottom-sheet | Solo el import. Mismo API de props. |
| @react-native-community/datetimepicker | @expo/ui/community/datetime-picker | Solo el import. Verificar prop mode. |
| @react-native-segmented-control/segmented-control | @expo/ui/community/segmented-control | Solo el import. Mismo API. |
components/NewActivitySheet.tsx
DROP-IN REPLACEMENTS
// ANTES — librerías comunitarias:
// import BottomSheet, { BottomSheetView } from '@gorhom/bottom-sheet';
// import DateTimePicker from '@react-native-community/datetimepicker';
// AHORA — @expo/ui drop-in replacements (mismo API):
import BottomSheet, { BottomSheetView } from '@expo/ui/community/bottom-sheet';
import DateTimePicker from '@expo/ui/community/datetime-picker';
import { Host, Column, Row, Text, Button, Slider } from '@expo/ui';
import { useRef, useState } from 'react';
export const NewActivitySheet = ({ onSave }) => {
const sheetRef = useRef(null);
const [date, setDate] = useState(new Date());
const [duration, setDuration] = useState(30);
return (
{/* BottomSheet — API idéntico a @gorhom/bottom-sheet */}
<BottomSheet ref={sheetRef} snapPoints={['50%', '80%']}>
<BottomSheetView>
{/* Árbol universal @expo/ui dentro del sheet */}
<Host matchContents>
<Column>
<Text>Nueva actividad</Text>
{/* Slider de duración (universal) */}
<Slider
value={duration}
minimumValue={5}
maximumValue={180}
step={5}
onValueChange={setDuration}
/>
<Text>{duration} minutos</Text>
</Column>
</Host>
{/* DateTimePicker — API idéntico a @react-native-community/datetimepicker */}
<DateTimePicker
value={date}
mode="date"
onChange={(_e, d) => d && setDate(d)}
/>
</BottomSheetView>
</BottomSheet>
);
};
iOS Platform-Specific — Input con worklet (SwiftUI)
Solo cuando la capa universal no es suficiente · archivo .ios.tsx
En FitTrack Pro, iOS necesita formatear el nombre de la actividad en tiempo real (mayúsculas automáticas, sin espera de re-render). La capa universal de
TextInput usa useNativeState, pero para la lógica custom de transformación necesitamos bajarlo al árbol SwiftUI.components/ActivityNameInput.ios.tsx
SwiftUI · iOS only
import { Host, TextInput, useNativeState } from '@expo/ui';
// react-native-worklets requerido para sincronía en el UI thread
import { useCallback } from 'react';
export const ActivityNameInput = ({ onName }) => {
// ObservableState: se actualiza en el UI thread sin React re-render
const nameState = useNativeState('');
const handleChange = useCallback((raw: string) => {
'worklet'; // corre en el UI thread — sin flicker
// Capitaliza primera letra de cada palabra (title case)
const formatted = raw
.split(' ')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
nameState.value = formatted; // escribe directo — sin re-render
onName(formatted);
}, [nameState]);
return (
<Host matchContents>
<TextInput
value={nameState} {/* ObservableState, no string */}
onChangeText={handleChange}
placeholder="Nombre de la actividad"
/>
</Host>
);
};
// Android fallback → components/ActivityNameInput.android.tsx
// usa TextInput estándar de React Native (sin worklet necesario)
Android Platform-Specific — LazyColumn (Jetpack Compose)
Historial largo de actividades · archivo .android.tsx
List universal no es apto para listas largas. El historial de FitTrack Pro puede tener cientos de actividades — en Android usamos LazyColumn de Jetpack Compose con Host style={{ flex: 1 }} (tamaño explícito obligatorio para LazyColumn).components/ActivityHistory.android.tsx
Jetpack Compose · Android only
import { Host, LazyColumn, Text, Row } from '@expo/ui/jetpack-compose';
import { paddingAll, fillMaxWidth } from '@expo/ui/jetpack-compose/modifiers';
import { Activity } from '../types';
export const ActivityHistory = ({ activities }: { activities: Activity[] }) => (
{/* flex: 1 OBLIGATORIO cuando LazyColumn es hijo directo de Host */}
<Host style={{ flex: 1 }}>
<LazyColumn
verticalArrangement={{ spacedBy: 8 }}
modifiers={[paddingAll(16)]}
>
{activities.map((act) => (
<Row
key={act.id}
modifiers={[fillMaxWidth(), paddingAll(12)]}
horizontalArrangement={{ spacedBy: 12 }}
>
<Text style={{ typography: 'titleMedium' }}>{act.name}</Text>
<Text style={{ typography: 'bodySmall' }}>
{act.duration} min · {act.date}
</Text>
</Row>
))}
</LazyColumn>
</Host>
);
Resumen de la migración FitTrack Pro
Qué capa se usa en cada pantalla
| Pantalla / Componente | Capa @expo/ui | Archivo |
|---|---|---|
| Dashboard — métricas y lista actividades | Universal | Dashboard.tsx |
| Filtro Hoy / Semana / Mes / Año | Drop-in (SegmentedControl) | Dashboard.tsx |
| Panel nueva actividad (BottomSheet) | Drop-in (BottomSheet) | NewActivitySheet.tsx |
| Selector de fecha en el panel | Drop-in (DateTimePicker) | NewActivitySheet.tsx |
| Input nombre actividad — title case sin flicker | SwiftUI (useNativeState) | ActivityNameInput.ios.tsx |
| Historial largo de actividades (Android) | Jetpack Compose (LazyColumn) | ActivityHistory.android.tsx |