react-native-reanimated
Version:
More powerful alternative to Animated library for React Native.
629 lines (569 loc) • 17.6 kB
text/typescript
;
import type { ViewStyle } from 'react-native';
import type { ValueProcessor, ValueProcessorContext } from '../../types';
import {
CSS_NUMBER_PATTERN,
getAngleInDegrees,
isPercentage,
splitByComma,
splitByWhitespace,
} from '../../utils';
import type { ProcessedColor } from './colors';
import { processColor, processColorNumber } from './colors';
type BackgroundImageStyleValue = NonNullable<
ViewStyle[Extract<
keyof ViewStyle,
'backgroundImage' | 'experimental_backgroundImage'
>]
>;
type BackgroundImageValue = Exclude<BackgroundImageStyleValue, string>[number];
type RadialGradientValue = Extract<
BackgroundImageValue,
{ type: 'radial-gradient' }
>;
type RadialGradientShape = RadialGradientValue['shape'];
type RadialGradientSize = RadialGradientValue['size'];
type RadialGradientPosition = RadialGradientValue['position'];
const NEWLINE_REGEX = /\n/g;
const WHITESPACE_NORMALIZE_REGEX = /\s+/g;
const GRADIENT_REGEX = /^(linear|radial)-gradient\(((?:\([^)]*\)|[^()])*)\)/;
const COLOR_STOP_PARTS_REGEX = /\S+\([^)]*\)|\S+/g;
const PX_LENGTH_REGEX = new RegExp(`^${CSS_NUMBER_PATTERN}px$`);
const LINEAR_GRADIENT_DIRECTION_REGEX =
/^to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?/;
const DEFAULT_DIRECTION = { type: 'angle', value: 180 } as const;
const DEFAULT_RADIAL_SHAPE = 'ellipse';
const DEFAULT_RADIAL_SIZE = 'farthest-corner';
const DEFAULT_RADIAL_POSITION = { top: '50%', left: '50%' };
const MIN_COLOR_STOPS = 2;
const RADIAL_SIZE_KEYWORDS = [
'closest-side',
'closest-corner',
'farthest-side',
'farthest-corner',
];
export const ERROR_MESSAGES = {
invalidDirection(direction: string) {
'worklet';
return `Invalid direction "${direction}" in background image.`;
},
invalidPosition(position: unknown) {
'worklet';
return `Invalid position "${String(position)}" in background image color stop.`;
},
invalidShape(shape: string) {
'worklet';
return `Invalid shape "${shape}" in radial gradient.`;
},
invalidSize(size: unknown) {
'worklet';
return `Invalid size ${JSON.stringify(size)} in radial gradient.`;
},
invalidTransitionHint(position: unknown) {
'worklet';
return `Invalid transition hint "${String(position)}" in background image: a hint must be placed between two color stops.`;
},
notEnoughColorStops(count: number) {
'worklet';
return `Background image gradient must have at least two color stops, got ${count}.`;
},
};
export type ProcessedDirection =
| { type: 'angle'; value: number }
| { type: 'keyword'; value: string };
export type ProcessedColorStop = {
color: ProcessedColor | null;
position: number | string | null;
};
export type ProcessedBackgroundImageValue =
| {
type: 'linear-gradient';
direction: ProcessedDirection;
colorStops: ProcessedColorStop[];
}
| {
type: 'radial-gradient';
shape: RadialGradientShape;
size: RadialGradientSize;
position: RadialGradientPosition;
colorStops: ProcessedColorStop[];
};
const getDirectionForKeyword = (
direction: string
): ProcessedDirection | null => {
'worklet';
switch (direction.replace(WHITESPACE_NORMALIZE_REGEX, ' ')) {
case 'to top':
return { type: 'angle', value: 0 };
case 'to right':
return { type: 'angle', value: 90 };
case 'to bottom':
return { type: 'angle', value: 180 };
case 'to left':
return { type: 'angle', value: 270 };
case 'to top right':
case 'to right top':
return { type: 'keyword', value: 'to top right' };
case 'to bottom right':
case 'to right bottom':
return { type: 'keyword', value: 'to bottom right' };
case 'to top left':
case 'to left top':
return { type: 'keyword', value: 'to top left' };
case 'to bottom left':
case 'to left bottom':
return { type: 'keyword', value: 'to bottom left' };
default:
return null;
}
};
const parseDirection = (direction: string): ProcessedDirection | null => {
'worklet';
const normalized = direction.toLowerCase();
const angle = getAngleInDegrees(normalized);
if (angle !== null) {
return { type: 'angle', value: angle };
}
return getDirectionForKeyword(normalized);
};
const processDirection = (direction?: string): ProcessedDirection => {
'worklet';
if (direction == null) {
return DEFAULT_DIRECTION;
}
const parsed = parseDirection(direction);
if (parsed === null) {
throw new Error(
`[Reanimated] ${ERROR_MESSAGES.invalidDirection(direction)}`
);
}
return parsed;
};
const isValidPosition = (position: unknown): position is number | string => {
'worklet';
return typeof position === 'number' || isPercentage(position);
};
const processColorStops = (
colorStops: BackgroundImageValue['colorStops'],
context?: ValueProcessorContext
): ProcessedColorStop[] => {
'worklet';
const result: ProcessedColorStop[] = [];
for (const colorStop of colorStops) {
const { color, positions } = colorStop;
if (color == null && positions?.length === 1) {
if (!isValidPosition(positions[0])) {
throw new Error(
`[Reanimated] ${ERROR_MESSAGES.invalidPosition(positions[0])}`
);
}
result.push({ color: null, position: positions[0] });
continue;
}
const processedColor = processColor(color, context);
if (!positions?.length) {
result.push({ color: processedColor, position: null });
continue;
}
for (const position of positions) {
if (!isValidPosition(position)) {
throw new Error(
`[Reanimated] ${ERROR_MESSAGES.invalidPosition(position)}`
);
}
result.push({ color: processedColor, position });
}
}
for (let i = 0; i < result.length; i++) {
const { color, position } = result[i];
if (
color === null &&
(i === 0 || i === result.length - 1 || result[i - 1].color === null)
) {
throw new Error(
`[Reanimated] ${ERROR_MESSAGES.invalidTransitionHint(position)}`
);
}
}
if (result.length < MIN_COLOR_STOPS) {
throw new Error(
`[Reanimated] ${ERROR_MESSAGES.notEnoughColorStops(result.length)}`
);
}
return result;
};
const processRadialSize = (size?: RadialGradientSize): RadialGradientSize => {
'worklet';
if (size == null) {
return DEFAULT_RADIAL_SIZE;
}
if (typeof size === 'string' && RADIAL_SIZE_KEYWORDS.includes(size)) {
return size;
}
if (typeof size === 'object' && size.x != null && size.y != null) {
return { x: size.x, y: size.y };
}
throw new Error(`[Reanimated] ${ERROR_MESSAGES.invalidSize(size)}`);
};
const processRadialShape = (
shape?: RadialGradientShape
): RadialGradientShape => {
'worklet';
if (shape == null) {
return DEFAULT_RADIAL_SHAPE;
}
if (shape !== 'circle' && shape !== 'ellipse') {
throw new Error(`[Reanimated] ${ERROR_MESSAGES.invalidShape(shape)}`);
}
return shape;
};
const getPositionFromCSSValue = (value: string): number | string | null => {
'worklet';
if (isPercentage(value)) {
return value;
}
if (PX_LENGTH_REGEX.test(value)) {
return parseFloat(value);
}
return null;
};
const parseCSSColor = (
color: string,
context?: ValueProcessorContext
): ProcessedColor | null => {
'worklet';
if (processColorNumber(color) === null) {
return null;
}
return processColor(color, context);
};
const isLengthOrPercentageToken = (token: string): boolean => {
'worklet';
return token.endsWith('px') || token.endsWith('%');
};
const parseColorStopsCSSString = (
parts: string[],
context?: ValueProcessorContext
): ProcessedColorStop[] | null => {
'worklet';
const stops = splitByComma(parts.join(','));
const result: ProcessedColorStop[] = [];
let previousWasHint = false;
for (let i = 0; i < stops.length; i++) {
const colorStopParts = stops[i].trim().match(COLOR_STOP_PARTS_REGEX);
if (colorStopParts === null) {
return null;
}
if (colorStopParts.length === 3) {
const color = parseCSSColor(colorStopParts[0], context);
const position1 = getPositionFromCSSValue(colorStopParts[1]);
const position2 = getPositionFromCSSValue(colorStopParts[2]);
if (color === null || position1 === null || position2 === null) {
return null;
}
result.push({ color, position: position1 });
result.push({ color, position: position2 });
previousWasHint = false;
} else if (colorStopParts.length === 2) {
const color = parseCSSColor(colorStopParts[0], context);
const position = getPositionFromCSSValue(colorStopParts[1]);
if (color === null || position === null) {
return null;
}
result.push({ color, position });
previousWasHint = false;
} else if (colorStopParts.length === 1) {
const position = getPositionFromCSSValue(colorStopParts[0]);
if (position !== null) {
if (previousWasHint || i === 0 || i === stops.length - 1) {
return null;
}
result.push({ color: null, position });
previousWasHint = true;
} else {
const color = parseCSSColor(colorStopParts[0], context);
if (color === null) {
return null;
}
result.push({ color, position: null });
previousWasHint = false;
}
} else {
return null;
}
}
if (result.length < MIN_COLOR_STOPS) {
return null;
}
return result;
};
const parseLinearGradientCSSString = (
content: string,
context?: ValueProcessorContext
): ProcessedBackgroundImageValue | null => {
'worklet';
const parts = content.split(',');
const firstPart = parts[0].trim();
let direction: ProcessedDirection = DEFAULT_DIRECTION;
if (
getAngleInDegrees(firstPart) !== null ||
LINEAR_GRADIENT_DIRECTION_REGEX.test(firstPart)
) {
const parsed = parseDirection(firstPart);
if (parsed === null) {
return null;
}
direction = parsed;
parts.shift();
}
const colorStops = parseColorStopsCSSString(parts, context);
if (colorStops === null) {
return null;
}
return { type: 'linear-gradient', direction, colorStops };
};
const HORIZONTAL_POSITION_KEYWORDS: Record<string, string> = {
left: '0%',
center: '50%',
right: '100%',
};
const VERTICAL_POSITION_KEYWORDS: Record<string, string> = {
top: '0%',
center: '50%',
bottom: '100%',
};
const getKeywordPosition = (
keywords: Record<string, string>,
token: string
): string | null => {
'worklet';
return Object.prototype.hasOwnProperty.call(keywords, token)
? keywords[token]
: null;
};
const parseRadialPosition = (
tokens: string[]
): RadialGradientPosition | null => {
'worklet';
let top: string | number | undefined;
let left: string | number | undefined;
let right: string | number | undefined;
let bottom: string | number | undefined;
if (tokens.length === 1) {
const token = tokens[0];
const horizontal = getKeywordPosition(HORIZONTAL_POSITION_KEYWORDS, token);
const vertical = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token);
if (horizontal !== null) {
left = horizontal;
top = '50%';
} else if (vertical !== null) {
left = '50%';
top = vertical;
} else if (isLengthOrPercentageToken(token)) {
const value = getPositionFromCSSValue(token);
if (value === null) {
return null;
}
left = value;
top = '50%';
}
} else if (tokens.length === 2) {
const [token1, token2] = tokens;
const horizontal1 = getKeywordPosition(
HORIZONTAL_POSITION_KEYWORDS,
token1
);
const vertical1 = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token1);
const horizontal2 = getKeywordPosition(
HORIZONTAL_POSITION_KEYWORDS,
token2
);
const vertical2 = getKeywordPosition(VERTICAL_POSITION_KEYWORDS, token2);
if (horizontal1 !== null && vertical2 !== null) {
left = horizontal1;
top = vertical2;
} else if (vertical1 !== null && horizontal2 !== null) {
left = horizontal2;
top = vertical1;
} else {
if (horizontal1 !== null) {
left = horizontal1;
} else if (isLengthOrPercentageToken(token1)) {
const value = getPositionFromCSSValue(token1);
if (value === null) {
return null;
}
left = value;
} else {
return null;
}
if (vertical2 !== null) {
top = vertical2;
} else if (isLengthOrPercentageToken(token2)) {
const value = getPositionFromCSSValue(token2);
if (value === null) {
return null;
}
top = value;
} else {
return null;
}
}
} else if (tokens.length === 4) {
for (const [keyword, rawValue] of [
[tokens[0], tokens[1]],
[tokens[2], tokens[3]],
]) {
const value = getPositionFromCSSValue(rawValue);
if (value === null) {
return null;
}
if (keyword === 'left') {
left = value;
} else if (keyword === 'right') {
right = value;
} else if (keyword === 'top') {
top = value;
} else if (keyword === 'bottom') {
bottom = value;
} else {
return null;
}
}
}
if (top != null && left != null) {
return { top, left };
}
if (bottom != null && right != null) {
return { bottom, right };
}
if (top != null && right != null) {
return { top, right };
}
if (bottom != null && left != null) {
return { bottom, left };
}
return null;
};
const parseRadialGradientCSSString = (
content: string,
context?: ValueProcessorContext
): ProcessedBackgroundImageValue | null => {
'worklet';
let shape: RadialGradientShape = DEFAULT_RADIAL_SHAPE;
let size: RadialGradientSize = DEFAULT_RADIAL_SIZE;
let position: RadialGradientPosition = { ...DEFAULT_RADIAL_POSITION };
const parts = splitByComma(content);
const tokens = splitByWhitespace(parts[0]);
let hasShapeSizeOrPosition = false;
let hasExplicitSingleSize = false;
let hasExplicitShape = false;
while (tokens.length > 0) {
const token = tokens.shift()!;
if (token === 'circle' || token === 'ellipse') {
shape = token;
hasShapeSizeOrPosition = true;
hasExplicitShape = true;
} else if (RADIAL_SIZE_KEYWORDS.includes(token)) {
size = token as RadialGradientSize;
hasShapeSizeOrPosition = true;
} else if (isLengthOrPercentageToken(token)) {
const sizeX = getPositionFromCSSValue(token);
if (sizeX === null || parseFloat(token) < 0) {
return null;
}
hasShapeSizeOrPosition = true;
size = { x: sizeX, y: sizeX };
const nextToken = tokens[0];
if (nextToken !== undefined && isLengthOrPercentageToken(nextToken)) {
tokens.shift();
const sizeY = getPositionFromCSSValue(nextToken);
if (sizeY === null || parseFloat(nextToken) < 0) {
return null;
}
size = { x: sizeX, y: sizeY };
} else {
hasExplicitSingleSize = true;
}
} else if (token === 'at') {
hasShapeSizeOrPosition = true;
const parsedPosition = parseRadialPosition(tokens.splice(0));
if (parsedPosition === null) {
return null;
}
position = parsedPosition;
}
if (!hasShapeSizeOrPosition) {
break;
}
}
if (hasShapeSizeOrPosition) {
parts.shift();
if (!hasExplicitShape && hasExplicitSingleSize) {
shape = 'circle';
}
if (hasExplicitSingleSize && hasExplicitShape && shape === 'ellipse') {
return null;
}
}
const colorStops = parseColorStopsCSSString(parts, context);
if (colorStops === null) {
return null;
}
return { type: 'radial-gradient', shape, size, position, colorStops };
};
const parseBackgroundImageCSSString = (
value: string,
context?: ValueProcessorContext
): ProcessedBackgroundImageValue[] => {
'worklet';
const result: ProcessedBackgroundImageValue[] = [];
for (const gradientString of splitByComma(
value.replace(NEWLINE_REGEX, ' ')
)) {
const match = GRADIENT_REGEX.exec(gradientString.toLowerCase());
if (!match) {
continue;
}
const [, type, content] = match;
const gradient =
type === 'radial'
? parseRadialGradientCSSString(content, context)
: parseLinearGradientCSSString(content, context);
if (gradient !== null) {
result.push(gradient);
}
}
return result;
};
export const processBackgroundImage: ValueProcessor<
BackgroundImageStyleValue,
ProcessedBackgroundImageValue[] | undefined
> = (value, context) => {
'worklet';
if (typeof value === 'string') {
return parseBackgroundImageCSSString(value, context);
}
if (!Array.isArray(value)) {
return;
}
const result: ProcessedBackgroundImageValue[] = [];
for (const backgroundImage of value) {
if (backgroundImage.type === 'linear-gradient') {
result.push({
type: 'linear-gradient',
direction: processDirection(backgroundImage.direction),
colorStops: processColorStops(backgroundImage.colorStops, context),
});
} else if (backgroundImage.type === 'radial-gradient') {
result.push({
type: 'radial-gradient',
shape: processRadialShape(backgroundImage.shape),
size: processRadialSize(backgroundImage.size),
position: backgroundImage.position ?? { ...DEFAULT_RADIAL_POSITION },
colorStops: processColorStops(backgroundImage.colorStops, context),
});
}
}
return result;
};