
Первый запуск и онбординг — стартовый кадр приложения.
npx astra-rn add welcome-v1pnpm dlx astra-rn add welcome-v1yarn dlx astra-rn add welcome-v1bunx --bun astra-rn add welcome-v1npm install @shopify/react-native-skia react-native-safe-area-context react-native-svgpnpm add @shopify/react-native-skia react-native-safe-area-context react-native-svgyarn add @shopify/react-native-skia react-native-safe-area-context react-native-svgbun add @shopify/react-native-skia react-native-safe-area-context react-native-svgimport { Canvas, Fill, Shader, Skia } from "@shopify/react-native-skia";
import React, { memo, useCallback, useMemo, useState } from "react";
import {
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
} from "react-native";
import Svg, { Path } from "react-native-svg";
import {
ACTION_GAP,
ACTION_HEIGHT,
ACTION_RADIUS,
COLORS,
DEFAULT_ACTIONS,
DEFAULT_SUBTITLE,
DEFAULT_TITLE,
GRADIENT_ANGLE,
GRADIENT_ASPECT,
GRADIENT_BEND,
GRADIENT_RATIO,
GRADIENT_SEED,
GRADIENT_SHADER,
GRADIENT_SWIRL,
GRADIENT_WARP,
GRAIN_INTENSITY,
ICON_SIZE,
LOGO_SIZE,
MESH_COLORS,
SHEET_HORIZONTAL_PADDING,
SHEET_OVERLAP,
SHEET_RADIUS,
} from "./const";
import type {
IGlyph,
IWelcomeAction,
IWelcomeActionRow,
IWelcomeScreen,
} from "./types";
import { useSafeAreaInsets } from "react-native-safe-area-context";
const AppleGlyph: React.FC<IGlyph> = ({
size = ICON_SIZE,
color = COLORS.primaryLabel,
}: IGlyph) => (
<Svg width={size * 1.3} height={size * 1.3} viewBox="0 0 24 24" fill="none">
<Path
d="M17.02 12.54c.02 2.19 1.92 2.92 1.94 2.93-.02.05-.3 1.04-1 2.06-.6.88-1.23 1.76-2.22 1.78-.97.02-1.28-.58-2.39-.58s-1.45.56-2.37.6c-.95.03-1.68-.95-2.28-1.83-1.25-1.8-2.2-5.08-.92-7.3.63-1.1 1.76-1.8 2.99-1.81.93-.02 1.82.63 2.39.63.57 0 1.64-.78 2.77-.67.47.02 1.8.19 2.65 1.44-.07.04-1.58.92-1.56 2.75M15.31 6.02c.5-.61.84-1.46.75-2.31-.73.03-1.61.49-2.13 1.1-.47.54-.88 1.4-.77 2.23.81.06 1.64-.41 2.15-1.02"
fill={color}
/>
</Svg>
);
const GoogleGlyph: React.FC<IGlyph> = ({ size = ICON_SIZE }: IGlyph) => (
<Svg width={size} height={size} viewBox="0 0 48 48">
<Path
fill="#4285F4"
d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z"
/>
<Path
fill="#34A853"
d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z"
/>
<Path
fill="#FBBC05"
d="M11.69 28.18c-.44-1.32-.69-2.73-.69-4.18s.25-2.86.69-4.18v-5.7H4.34A21.99 21.99 0 0 0 2 24c0 3.55.85 6.91 2.34 9.88l7.35-5.7z"
/>
<Path
fill="#EA4335"
d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z"
/>
</Svg>
);
const EmailGlyph: React.FC<IGlyph> = ({
size = ICON_SIZE,
color = COLORS.secondaryLabel,
}: IGlyph) => (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
<Path
d="M3 7.5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
fill={color}
/>
<Path
d="m4.4 7.6 6.44 5.02a1.9 1.9 0 0 0 2.32 0L19.6 7.6"
stroke={COLORS.secondary}
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
const ICONS: Record<IWelcomeAction["icon"], React.FC<IGlyph>> = {
apple: AppleGlyph,
google: GoogleGlyph,
email: EmailGlyph,
};
const ActionRow: React.FC<IWelcomeActionRow> = memo(
({ action, onPress }: IWelcomeActionRow) => {
const [pressed, setPressed] = useState<boolean>(false);
const primary = action.variant !== "secondary";
const Icon = ICONS[action.icon];
const handlePress = useCallback(() => onPress?.(action), [action, onPress]);
const handlePressIn = useCallback(() => setPressed(true), []);
const handlePressOut = useCallback(() => setPressed(false), []);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={action.label}
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
style={[
styles.action,
primary ? styles.actionPrimary : styles.actionSecondary,
pressed
? primary
? styles.actionPrimaryPressed
: styles.actionSecondaryPressed
: null,
]}
>
<View style={styles.actionInner}>
<Icon
size={ICON_SIZE}
color={primary ? COLORS.primaryLabel : COLORS.secondaryLabel}
/>
<Text
style={[
styles.actionLabel,
primary ? styles.actionLabelPrimary : styles.actionLabelSecondary,
]}
numberOfLines={1}
>
{action.label}
</Text>
</View>
</Pressable>
);
},
);
const WelcomeScreen: React.FC<IWelcomeScreen> = ({
title = DEFAULT_TITLE,
subtitle = DEFAULT_SUBTITLE,
actions = DEFAULT_ACTIONS,
logo,
gradientRatio = GRADIENT_RATIO,
style,
onActionPress,
}: IWelcomeScreen) => {
const { width, height } = useWindowDimensions();
const panelHeight = Math.round(height * gradientRatio);
const effect = useMemo(() => Skia.RuntimeEffect.Make(GRADIENT_SHADER), []);
const uniforms = useMemo(
() => ({
resolution: [width, panelHeight],
seed: GRADIENT_SEED,
warp: GRADIENT_WARP,
angle: GRADIENT_ANGLE,
swirl: GRADIENT_SWIRL,
bend: GRADIENT_BEND,
aspect: GRADIENT_ASPECT,
grain: GRAIN_INTENSITY,
c1: MESH_COLORS.blush,
c2: MESH_COLORS.coral,
c3: MESH_COLORS.indigo,
c4: MESH_COLORS.violet,
}),
[panelHeight, width],
);
const insets = useSafeAreaInsets();
return (
<View style={[styles.container, style]}>
<View style={[styles.panel, { height: panelHeight }]}>
{effect ? (
<Canvas style={StyleSheet.absoluteFill}>
<Fill>
<Shader source={effect} uniforms={uniforms} />
</Fill>
</Canvas>
) : null}
</View>
<View
style={[
styles.sheet,
{
paddingTop: insets.top - insets.bottom * 0.2,
},
]}
>
<Text style={styles.title}>{title}</Text>
{subtitle ? <Text style={styles.subtitle}>{subtitle}</Text> : null}
<View style={styles.actions}>
{actions.map((action) => (
<ActionRow
key={action.key}
action={action}
onPress={onActionPress}
/>
))}
</View>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: COLORS.sheet,
},
panel: {
width: "100%",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
logo: {
alignItems: "center",
justifyContent: "center",
},
sheet: {
flex: 1,
alignItems: "center",
marginTop: -SHEET_OVERLAP,
paddingHorizontal: SHEET_HORIZONTAL_PADDING,
borderTopLeftRadius: SHEET_RADIUS,
borderTopRightRadius: SHEET_RADIUS,
backgroundColor: COLORS.sheet,
},
title: {
fontSize: 20,
lineHeight: 26,
fontWeight: "700",
letterSpacing: -0.45,
textAlign: "center",
color: COLORS.title,
},
subtitle: {
marginTop: 7,
fontSize: 15.5,
lineHeight: 21,
letterSpacing: -0.2,
textAlign: "center",
color: COLORS.subtitle,
},
actions: {
width: "100%",
marginTop: 26,
gap: ACTION_GAP,
},
action: {
width: "100%",
height: ACTION_HEIGHT,
borderRadius: ACTION_RADIUS,
overflow: "hidden",
},
actionInner: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
paddingHorizontal: 18,
},
actionPrimary: {
backgroundColor: COLORS.primary,
},
actionPrimaryPressed: {
backgroundColor: COLORS.primaryPressed,
},
actionSecondary: {
backgroundColor: COLORS.secondary,
},
actionSecondaryPressed: {
backgroundColor: COLORS.secondaryPressed,
},
actionLabel: {
fontSize: 17,
fontWeight: "600",
letterSpacing: -0.35,
},
actionLabelPrimary: {
color: COLORS.primaryLabel,
},
actionLabelSecondary: {
color: COLORS.secondaryLabel,
},
});
export { WelcomeScreen };
export type {
IGlyph,
IWelcomeAction,
IWelcomeActionRow,
IWelcomeScreen,
WelcomeActionVariant,
} from "./types";
export default memo(WelcomeScreen);import type { IWelcomeAction } from "./types";
export const DEFAULT_TITLE = "Your Music. Your Sound. Your Way.";
export const DEFAULT_SUBTITLE = "Discover music, create songs.";
export const DEFAULT_ACTIONS: IWelcomeAction[] = [
{
key: "apple",
label: "Sign in with Apple",
icon: "apple",
variant: "primary",
},
{
key: "google",
label: "Sign in with Google",
icon: "google",
variant: "secondary",
},
{
key: "email",
label: "Continue with email",
icon: "email",
variant: "secondary",
},
];
export const GRADIENT_RATIO = 0.58;
export const MESH_COLORS = {
violet: [0.478, 0.294, 0.827],
indigo: [0.337, 0.392, 0.855],
blush: [0.902, 0.376, 0.518],
coral: [0.957, 0.541, 0.451],
} as const;
export const GRADIENT_SEED = 1.1;
export const GRADIENT_WARP = 24;
export const GRADIENT_ANGLE = -24;
export const GRADIENT_SWIRL = 24;
export const GRADIENT_BEND = 3.5;
export const GRAIN_INTENSITY = 0.0882;
export const GRADIENT_ASPECT = 1.7;
export const GRADIENT_SHADER = `
uniform float2 resolution;
uniform float seed;
uniform float warp;
uniform float angle;
uniform float swirl;
uniform float bend;
uniform float aspect;
uniform float grain;
uniform half3 c1;
uniform half3 c2;
uniform half3 c3;
uniform half3 c4;
float2x2 rot(float a) {
float s = sin(a);
float c = cos(a);
return float2x2(c, -s, s, c);
}
float2 hash(float2 p) {
p = float2(dot(p, float2(2127.1, 81.17)), dot(p, float2(1269.5, 283.37)));
return fract(sin(p) * 43758.5453);
}
float noise(float2 p) {
float2 i = floor(p);
float2 f = fract(p);
float2 u = f * f * (3.0 - 2.0 * f);
float n = mix(
mix(dot(-1.0 + 2.0 * hash(i + float2(0.0, 0.0)), f - float2(0.0, 0.0)),
dot(-1.0 + 2.0 * hash(i + float2(1.0, 0.0)), f - float2(1.0, 0.0)), u.x),
mix(dot(-1.0 + 2.0 * hash(i + float2(0.0, 1.0)), f - float2(0.0, 1.0)),
dot(-1.0 + 2.0 * hash(i + float2(1.0, 1.0)), f - float2(1.0, 1.0)), u.x),
u.y);
return 0.5 + 0.5 * n;
}
half4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution;
float2 tuv = uv - 0.5;
float degree = noise(float2(seed, tuv.x * tuv.y)) - 0.5;
tuv.y *= 1.0 / aspect;
tuv = tuv * rot(radians(angle + degree * swirl));
tuv.y *= aspect;
float frequency = 5.0;
float amplitude = bend;
tuv.x += sin(tuv.y * frequency + warp) / amplitude;
tuv.y += sin(tuv.x * frequency * 1.5 + warp) / (amplitude * 0.5);
float x = (tuv * rot(radians(-5.0))).x;
half3 layer1 = mix(c3, c2, half(smoothstep(-0.3, 0.2, x)));
half3 layer2 = mix(c4, c1, half(smoothstep(-0.3, 0.2, x)));
half3 color = mix(layer1, layer2, half(smoothstep(0.5, -0.3, tuv.y)));
color = color - half(length(hash(uv)) * grain);
return half4(color, 1.0);
}
`;
export const LOGO_SIZE = 62;
export const SHEET_HORIZONTAL_PADDING = 24;
export const SHEET_RADIUS = 0;
export const SHEET_OVERLAP = 34;
export const ACTION_HEIGHT = 56;
export const ACTION_RADIUS = ACTION_HEIGHT / 2;
export const ACTION_GAP = 12;
export const ICON_SIZE = 18;
export const COLORS = {
sheet: "#fbfbfb",
title: "#111111",
subtitle: "#8a8a8e",
primary: "#191919",
primaryPressed: "#000000",
primaryLabel: "#ffffff",
secondary: "#efeff0",
secondaryPressed: "#e4e4e6",
secondaryLabel: "#111111",
logo: "#ffffff",
} as const;import type { StyleProp, ViewStyle } from "react-native";
export interface IGlyph {
size?: number;
color?: string;
}
export type WelcomeActionVariant = "primary" | "secondary";
export interface IWelcomeAction {
key: string;
label: string;
icon: "apple" | "google" | "email";
variant?: WelcomeActionVariant;
}
export interface IWelcomeActionRow {
action: IWelcomeAction;
onPress?: (action: IWelcomeAction) => void;
}
export interface IWelcomeScreen {
title?: string;
subtitle?: string;
actions?: IWelcomeAction[];
logo?: React.ReactNode;
gradientRatio?: number;
style?: StyleProp<ViewStyle>;
onActionPress?: (action: IWelcomeAction) => void;
}