Theme und Dark Mode
Zum Schluss der Feinschliff: Die Karte im Wetter-Screen hat bisher eine
feste Farbe ('#BBDEFB') und feste Schriftgrößen. Ist das Gerät auf
Dark Mode eingestellt, bleibt die App trotzdem hell.
Anders als Compose und Flutter bringt React Native kein Material Design
mit, also auch kein fertiges Theme mit colorScheme und textTheme.
Wir legen deshalb selbst eine Datei mit Farben und Schriften an. Die
Namen orientieren sich an Material 3, damit der Vergleich leichtfällt.
Farben für hell und dunkel
Alle Farben stehen an einer Stelle, je einmal für hell und dunkel. Der Hook useColorScheme verrät, was am Gerät eingestellt ist.
export function useAppColors(): AppColors {
return useColorScheme() === 'dark' ? darkColors : lightColors;
}Neue Datei ui/theme.ts:
import { DarkTheme, DefaultTheme, type Theme } from 'expo-router';
import { StyleSheet, useColorScheme } from 'react-native';
const lightColors = {
background: '#F8F9FF',
text: '#191C20',
textSecondary: '#43474E',
primary: '#1565C0',
primaryContainer: '#D1E4FF',
onPrimaryContainer: '#001D36',
divider: '#C3C6CF',
pressed: '#E1E2E8',
};
const darkColors: AppColors = {
background: '#111418',
text: '#E1E2E8',
textSecondary: '#C3C6CF',
primary: '#9ECAFF',
primaryContainer: '#00497D',
onPrimaryContainer: '#D1E4FF',
divider: '#43474E',
pressed: '#2D3135',
};
export type AppColors = typeof lightColors;
// Farben passend zur Einstellung des Geräts (hell oder dunkel)
export function useAppColors(): AppColors {
return useColorScheme() === 'dark' ? darkColors : lightColors;
}
// Farben für Header und Hintergrund der Screens von Expo Router
export const lightNavigationTheme: Theme = {
...DefaultTheme,
colors: {
...DefaultTheme.colors,
primary: lightColors.primary,
background: lightColors.background,
card: lightColors.background,
text: lightColors.text,
border: lightColors.divider,
},
};
export const darkNavigationTheme: Theme = {
...DarkTheme,
colors: {
...DarkTheme.colors,
primary: darkColors.primary,
background: darkColors.background,
card: darkColors.background,
text: darkColors.text,
border: darkColors.divider,
},
};
// Schriftgrößen an einer Stelle, Namen wie in Material 3
export const typography = StyleSheet.create({
displayLarge: { fontSize: 57 },
headlineMedium: { fontSize: 28 },
titleMedium: { fontSize: 18 },
bodyLarge: { fontSize: 16 },
bodyMedium: { fontSize: 14 },
});typeof lightColors leitet den Typ aus dem Objekt ab: ein Objekt mit
genau diesen Feldern, alle vom Typ string. darkColors: AppColors
erzwingt, dass die dunklen Farben dieselben Namen haben. Fehlt eine,
meldet TypeScript einen Fehler.
useAppColors ist ein Custom Hook, weil er selbst den Hook
useColorScheme aufruft. Er übernimmt die Rolle von
MaterialTheme.colorScheme bzw. Theme.of(context).colorScheme.
Ändert sich die Einstellung am Gerät, rendert React jede Komponente neu,
die ihn verwendet.
Die Farben stammen aus einem Material-3-Farbschema mit Blau als Grundfarbe. Eigene Farbschemata lassen sich mit dem Material Theme Builder erzeugen.
...DefaultTheme (Spread) kopiert alle Felder des Standard-Themes von
Expo Router; die folgenden Zeilen überschreiben einzelne davon. So
bleiben z. B. die Schriften für den Header erhalten.
ThemeProvider
Der ThemeProvider im Layout gibt Header und Hintergrund aller Screens die passenden Farben.
import { Stack, ThemeProvider } from 'expo-router';
import { useColorScheme } from 'react-native';
import { darkNavigationTheme, lightNavigationTheme } from '@/ui/theme';
export default function RootLayout() {
const colorScheme = useColorScheme();
return (
<ThemeProvider
value={
colorScheme === 'dark' ? darkNavigationTheme : lightNavigationTheme
}
>
<Stack />
</ThemeProvider>
);
}Das ist die vollständige neue app/_layout.tsx. Der ThemeProvider
stellt das Theme für alles darunter bereit, hier den Stack mit allen
Screens. Ohne ihn verwendet Expo Router immer das helle Standard-Theme.
app.json erlaubt der App schon beide Modi
("userInterfaceStyle": "automatic"). Steht dort "light", liefert
useColorScheme immer 'light'.
Farben in den Komponenten
Jede Komponente holt sich die Farben mit useAppColors(). Feste Styles bleiben im StyleSheet, Farben kommen als zweiter Style dazu.
const colors = useAppColors();
const textColor = { color: colors.onPrimaryContainer };
<View style={[styles.card, { backgroundColor: colors.primaryContainer }]}>
<Text style={[typography.headlineMedium, textColor]}>{city.name}</Text>Ein Array in style kombiniert mehrere Styles; bei gleichen
Eigenschaften gewinnt der letzte. StyleSheet.create kennt die Farben
nicht, weil sie erst beim Rendern feststehen.
In ui/weather-screen.tsx kommt ein Import hinzu:
import { typography, useAppColors } from '@/ui/theme';CurrentWeatherCard verwendet die Farben und Schriften aus dem Theme:
function CurrentWeatherCard({
city,
weather,
isCelsius,
}: CurrentWeatherCardProps) {
const colors = useAppColors();
const textColor = { color: colors.onPrimaryContainer };
return (
<View style={[styles.card, { backgroundColor: colors.primaryContainer }]}>
<Text style={[typography.headlineMedium, textColor]}>{city.name}</Text>
<Text style={styles.emoji}>{conditionEmoji[weather.condition]}</Text>
<Text style={[typography.displayLarge, textColor]}>
{formatTemperature(weather.temperature, isCelsius)}
</Text>
<View style={styles.row}>
<Text style={[typography.bodyLarge, textColor]}>
Wind: {Math.round(weather.windSpeed)} km/h
</Text>
<Text style={[typography.bodyLarge, textColor]}>
{conditionLabel[weather.condition]}
</Text>
</View>
</View>
);
}Nur das Emoji behält eine feste Größe, weil es im Theme keinen passenden Schriftstil dafür gibt.
Auch WeatherBody und UnitToggle holen sich die Farben. In
WeatherBody bekommen die Ladeanzeige und die Fehlermeldung eine Farbe:
function WeatherBody({ uiState, isCelsius }: WeatherBodyProps): ReactElement {
const colors = useAppColors();
switch (uiState.status) {
case 'loading':
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
case 'success':
return (
<ScrollView>
<CurrentWeatherCard
city={uiState.city}
weather={uiState.weather}
isCelsius={isCelsius}
/>
</ScrollView>
);
case 'error':
return (
<View style={styles.centered}>
<Text style={[typography.bodyLarge, { color: colors.text }]}>
{uiState.message}
</Text>
</View>
);
}
}UnitToggle bekommt die Farbe primary:
function UnitToggle({ isCelsius, onToggle }: UnitToggleProps) {
const colors = useAppColors();
return (
<Pressable onPress={onToggle} hitSlop={12}>
<Text style={[styles.toggle, { color: colors.primary }]}>
{isCelsius ? '°C' : '°F'}
</Text>
</Pressable>
);
}In styles fallen die festen Farben und Schriftgrößen weg:
const styles = StyleSheet.create({
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
toggle: { fontSize: 18, paddingHorizontal: 8 },
card: {
margin: 16, // außen
padding: 24, // innen
borderRadius: 24,
alignItems: 'center',
},
emoji: { fontSize: 72 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignSelf: 'stretch',
marginTop: 16,
},
});Code: src/ui/weather-screen.tsx (schritt-10)
src/ui/weather-screen.tsx (schritt-10)import { Stack } from 'expo-router';
import { type ReactElement, useState } from 'react';
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from 'react-native';
import type { City } from '@/model/city';
import type { CurrentWeather } from '@/model/current-weather';
import { typography, useAppColors } from '@/ui/theme';
import { useWeather } from '@/ui/use-weather';
import {
conditionEmoji,
conditionLabel,
formatTemperature,
} from '@/ui/weather-condition-ui';
import type { WeatherUiState } from '@/ui/weather-ui-state';
type WeatherScreenProps = {
city: City;
};
// Holt den Zustand aus dem Hook und hält den UI-State für °C/°F
export function WeatherScreen({ city }: WeatherScreenProps) {
const uiState = useWeather(city);
const [isCelsius, setIsCelsius] = useState(true);
return (
<WeatherContent
city={city}
uiState={uiState}
isCelsius={isCelsius}
onToggleUnit={() => setIsCelsius(!isCelsius)}
/>
);
}
type WeatherContentProps = {
city: City;
uiState: WeatherUiState;
isCelsius: boolean;
onToggleUnit: () => void;
};
// Zeigt nur an, was es über Props bekommt
function WeatherContent({
city,
uiState,
isCelsius,
onToggleUnit,
}: WeatherContentProps) {
return (
<>
<Stack.Screen
options={{
title: city.name,
headerRight: () => (
<UnitToggle isCelsius={isCelsius} onToggle={onToggleUnit} />
),
}}
/>
<WeatherBody uiState={uiState} isCelsius={isCelsius} />
</>
);
}
type WeatherBodyProps = {
uiState: WeatherUiState;
isCelsius: boolean;
};
function WeatherBody({ uiState, isCelsius }: WeatherBodyProps): ReactElement {
const colors = useAppColors();
switch (uiState.status) {
case 'loading':
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
case 'success':
return (
<ScrollView>
<CurrentWeatherCard
city={uiState.city}
weather={uiState.weather}
isCelsius={isCelsius}
/>
</ScrollView>
);
case 'error':
return (
<View style={styles.centered}>
<Text style={[typography.bodyLarge, { color: colors.text }]}>
{uiState.message}
</Text>
</View>
);
}
}
type UnitToggleProps = {
isCelsius: boolean;
onToggle: () => void;
};
function UnitToggle({ isCelsius, onToggle }: UnitToggleProps) {
const colors = useAppColors();
return (
<Pressable onPress={onToggle} hitSlop={12}>
<Text style={[styles.toggle, { color: colors.primary }]}>
{isCelsius ? '°C' : '°F'}
</Text>
</Pressable>
);
}
type CurrentWeatherCardProps = {
city: City;
weather: CurrentWeather;
isCelsius: boolean;
};
function CurrentWeatherCard({
city,
weather,
isCelsius,
}: CurrentWeatherCardProps) {
const colors = useAppColors();
const textColor = { color: colors.onPrimaryContainer };
return (
<View style={[styles.card, { backgroundColor: colors.primaryContainer }]}>
<Text style={[typography.headlineMedium, textColor]}>{city.name}</Text>
<Text style={styles.emoji}>{conditionEmoji[weather.condition]}</Text>
<Text style={[typography.displayLarge, textColor]}>
{formatTemperature(weather.temperature, isCelsius)}
</Text>
<View style={styles.row}>
<Text style={[typography.bodyLarge, textColor]}>
Wind: {Math.round(weather.windSpeed)} km/h
</Text>
<Text style={[typography.bodyLarge, textColor]}>
{conditionLabel[weather.condition]}
</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 16,
},
toggle: { fontSize: 18, paddingHorizontal: 8 },
card: {
margin: 16, // außen
padding: 24, // innen
borderRadius: 24,
alignItems: 'center',
},
emoji: { fontSize: 72 },
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignSelf: 'stretch',
marginTop: 16,
},
});Ein Text ohne Farbangabe kennt unser Theme nicht. Anders als in
Compose und Flutter übernimmt er keine Farbe daraus; auf iOS ist er z. B.
immer schwarz, auch im Dark Mode. Deshalb braucht jeder Text eine Farbe aus useAppColors().
Vergessene Stellen fallen erst im Dark Mode auf: schwarze Schrift auf
dunklem Grund. Die Vorlage von Expo löst das mit einer eigenen
Komponente ThemedText, die die Farbe selbst setzt.
In ui/city-list-screen.tsx holen sich CityRow und Divider die
Farben ebenso:
Code: src/ui/city-list-screen.tsx (schritt-10)
src/ui/city-list-screen.tsx (schritt-10)import { router, Stack } from 'expo-router';
import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native';
import type { City } from '@/model/city';
import { typography, useAppColors } from '@/ui/theme';
type CityListScreenProps = {
cities: City[];
};
export function CityListScreen({ cities }: CityListScreenProps) {
return (
<>
<Stack.Screen options={{ title: 'Städte' }} />
<FlatList
data={cities}
keyExtractor={(city) => city.name}
renderItem={({ item }) => <CityRow city={item} />}
ItemSeparatorComponent={Divider}
/>
</>
);
}
function CityRow({ city }: { city: City }) {
const colors = useAppColors();
function openWeather() {
// Navigieren = neuen Screen auf den Stack legen
router.push({
pathname: '/weather',
params: {
name: city.name,
latitude: city.latitude,
longitude: city.longitude,
},
});
}
return (
<Pressable
onPress={openWeather}
style={({ pressed }) => [
styles.row,
pressed && { backgroundColor: colors.pressed },
]}
>
<Text style={[typography.titleMedium, { color: colors.text }]}>
{city.name}
</Text>
<Text style={[typography.bodyMedium, { color: colors.textSecondary }]}>
{city.latitude.toFixed(2)}° N, {city.longitude.toFixed(2)}° O
</Text>
</Pressable>
);
}
function Divider() {
const colors = useAppColors();
return (
<View style={[styles.divider, { backgroundColor: colors.divider }]} />
);
}
const styles = StyleSheet.create({
row: { paddingHorizontal: 16, paddingVertical: 12 },
divider: { height: StyleSheet.hairlineWidth },
});Dark Mode
Weil alle Farben aus dem Theme kommen, folgt die App jetzt der Einstellung des Geräts.
- Emulator: Dark Mode in den Schnelleinstellungen umschalten
- Header, Hintergrund, Karte und Liste wechseln sofort die Farben
- kein Neustart nötig:
useColorSchememeldet die Änderung
Zum Testen im Emulator die Schnelleinstellungen von oben herunterziehen und Dark theme antippen. Die App muss dafür nicht neu starten.
Ein Gegenstück zu dynamicColor aus der Android-Vorlage gibt es nicht
eingebaut. Wer Material Design mit fertigen Komponenten wie Card oder
Button möchte, kann eine Bibliothek wie
React Native Paper verwenden. Sie
bringt Material 3 mit, die App sieht dann aber auch auf iOS nach Android
aus.
Code: schritt-10 ·
Änderungen
Weiterführendes Material
- Farbschemata in Expo
- useColorScheme
- Themes in React Navigation
- Material Design 3 : Richtlinien und Farben
- React Native Paper : Material-3-Komponenten