Skip to content

Commit e0595d8

Browse files
authored
fix: Implement correct transparent color interpolation (#8398)
## Summary This PR fixes the `interpolateColor` interpolation between non-transparent colors and `'transparent'` (keyword) color. The previous implementation was incorrect as it converted `'transparent'` to the `0x00000000` (black transparent), but the `'transparent'` keyword is not just a black transparent color. Because of that, the interpolation from/to the transparent color always changed the color to black (the difference can be seen on the recordings below). ## Example recordings If you pause recordings and compare, you can see that the **Before** one goes through a dirty yellow to transparent because it becomes more and more black on each animation step. | Before | After | |-|-| | <video src="https://github.com/user-attachments/assets/860467b1-4ca9-4fad-9952-0886f131443b" /> | <video src="https://github.com/user-attachments/assets/a8320a68-924e-44bd-8806-5569b79504b8" /> | <details> <summary>Example source code</summary> ```tsx import React, { useEffect } from 'react'; import { StyleSheet } from 'react-native'; import Animated, { interpolateColor, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; export default function EmptyExample() { const sv = useSharedValue(0); const animatedStyle = useAnimatedStyle(() => ({ backgroundColor: interpolateColor( sv.value, [0, 1], ['yellow', 'transparent'] ), })); useEffect(() => { sv.value = 0; sv.value = withRepeat(withTiming(1, { duration: 3000 }), -1, true); }, [sv]); return <Animated.View style={[styles.container, animatedStyle]} />; } const styles = StyleSheet.create({ container: { flex: 1, }, }); ``` </details>
1 parent 8699490 commit e0595d8

File tree

5 files changed

+237
-64
lines changed

5 files changed

+237
-64
lines changed

packages/react-native-reanimated/__tests__/InterpolateColor.test.tsx

Lines changed: 116 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,22 +106,22 @@ describe('colors interpolation', () => {
106106
test('interpolates semi-transparent colors', () => {
107107
const colors = ['#10506050', '#60902070'];
108108
let interpolatedColor = interpolateColor(0.5, [0, 1], colors);
109-
expect(interpolatedColor).toBe(`rgba(71, 117, 73, ${96 / 255})`);
109+
expect(interpolatedColor).toBe('rgba(71, 117, 73, 0.376)');
110110

111111
interpolatedColor = interpolateColor(0, [0, 1], colors);
112-
expect(interpolatedColor).toBe(`rgba(16, 80, 96, ${80 / 255})`);
112+
expect(interpolatedColor).toBe('rgba(16, 80, 96, 0.314)');
113113

114114
interpolatedColor = interpolateColor(1, [0, 1], colors);
115-
expect(interpolatedColor).toBe(`rgba(96, 144, 32, ${112 / 255})`);
115+
expect(interpolatedColor).toBe('rgba(96, 144, 32, 0.439)');
116116

117117
interpolatedColor = interpolateColor(0.5, [0, 1], colors, 'HSV');
118-
expect(interpolatedColor).toBe(`rgba(23, 120, 54, ${96 / 255})`);
118+
expect(interpolatedColor).toBe('rgba(23, 120, 54, 0.376)');
119119

120120
interpolatedColor = interpolateColor(0, [0, 1], colors, 'HSV');
121-
expect(interpolatedColor).toBe(`rgba(16, 80, 96, ${80 / 255})`);
121+
expect(interpolatedColor).toBe('rgba(16, 80, 96, 0.314)');
122122

123123
interpolatedColor = interpolateColor(1, [0, 1], colors, 'HSV');
124-
expect(interpolatedColor).toBe(`rgba(96, 144, 32, ${112 / 255})`);
124+
expect(interpolatedColor).toBe('rgba(96, 144, 32, 0.439)');
125125
});
126126

127127
test('handles tiny values', () => {
@@ -132,6 +132,116 @@ describe('colors interpolation', () => {
132132
expect(interpolatedColor).toBe(`rgba(4, 2, 0, 0)`);
133133
});
134134

135+
describe('simple transparent to color interpolation', () => {
136+
const cases = [
137+
{
138+
name: 'transparent to color at midpoint',
139+
value: 0.5,
140+
inputRange: [0, 1],
141+
outputRange: ['transparent', '#ff0000'],
142+
expected: 'rgba(255, 0, 0, 0.5)',
143+
},
144+
{
145+
name: 'transparent at start position',
146+
value: 0,
147+
inputRange: [0, 1],
148+
outputRange: ['transparent', '#ff0000'],
149+
expected: 'rgba(255, 0, 0, 0)',
150+
},
151+
{
152+
name: 'color at end position',
153+
value: 1,
154+
inputRange: [0, 1],
155+
outputRange: ['transparent', '#ff0000'],
156+
expected: 'rgba(255, 0, 0, 1)',
157+
},
158+
{
159+
name: 'transparent to transparent',
160+
value: 0.5,
161+
inputRange: [0, 1],
162+
outputRange: ['transparent', 'transparent'],
163+
expected: 'rgba(0, 0, 0, 0)',
164+
},
165+
];
166+
167+
const colorSpaces: Array<{
168+
colorSpace: 'RGB' | 'HSV' | 'LAB';
169+
options?: Record<string, unknown>;
170+
eps?: number;
171+
}> = [
172+
{ colorSpace: 'RGB' },
173+
{ colorSpace: 'RGB', options: { gamma: 1 } },
174+
{ colorSpace: 'HSV' },
175+
{ colorSpace: 'HSV', options: { useCorrectedHSVInterpolation: false } },
176+
// LAB may produce slightly different results, but the differences are usually small
177+
{ colorSpace: 'LAB', eps: 1e-5 },
178+
];
179+
180+
colorSpaces.forEach(({ colorSpace, options, eps }) => {
181+
test.each(cases)(
182+
`$name using ${colorSpace}${options ? ` with options ${JSON.stringify(options)}` : ''}`,
183+
({ value, inputRange, outputRange, expected }) => {
184+
const result = interpolateColor(
185+
value,
186+
inputRange,
187+
outputRange,
188+
colorSpace,
189+
options
190+
);
191+
192+
if (eps) {
193+
const getChannels = (color: string) =>
194+
color
195+
.replace('rgba(', '')
196+
.replace(')', '')
197+
.split(',')
198+
.map((v) => parseFloat(v.trim()));
199+
200+
getChannels(result).forEach((v, i) => {
201+
expect(v).toBeCloseTo(getChannels(expected)[i], eps);
202+
});
203+
} else {
204+
expect(result).toBe(expected);
205+
}
206+
}
207+
);
208+
});
209+
});
210+
211+
describe('color interpolation with multiple transparent colors', () => {
212+
const inputRange = [0, 0.2, 0.4, 0.6, 0.8, 1];
213+
const outputRange = [
214+
'transparent',
215+
'transparent',
216+
'red',
217+
'transparent',
218+
'blue',
219+
'#00ff00',
220+
];
221+
222+
const cases: [number, string][] = [
223+
[0.1, 'rgba(255, 0, 0, 0)'], // red transparent
224+
[0.2, 'rgba(255, 0, 0, 0)'], // red transparent
225+
[0.3, 'rgba(255, 0, 0, 0.5)'], // between transparent red and red
226+
[0.4, 'rgba(255, 0, 0, 1)'], // red
227+
[0.5, 'rgba(255, 0, 0, 0.5)'], // between red and transparent red
228+
[0.6, 'rgba(255, 0, 0, 0)'], // red transparent
229+
[0.6000001, 'rgba(0, 0, 255, 0)'], // blue transparent
230+
[0.7, 'rgba(0, 0, 255, 0.5)'], // between transparent blue and blue
231+
[0.8, 'rgba(0, 0, 255, 1)'], // blue
232+
[0.9, 'rgba(0, 127.5, 127.5, 1)'], // between blue and green
233+
[1, 'rgba(0, 255, 0, 1)'], // green
234+
];
235+
236+
test.each(cases)(`for value %s, the result is %s`, (value, expected) => {
237+
expect(
238+
interpolateColor(value, inputRange, outputRange, 'RGB', {
239+
gamma: 1,
240+
})
241+
).toBe(expected);
242+
});
243+
});
244+
135245
function TestComponent() {
136246
const color = useSharedValue('#105060');
137247

packages/react-native-reanimated/__tests__/normalizeColor.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ describe('Test `normalizeColor` function', () => {
304304
describe('Test colors a colorName string', () => {
305305
test.each([
306306
['red', 0xff0000ff],
307-
['transparent', 0x00000000],
307+
['transparent', undefined], // Transparent cannot be represented as a number
308308
['peachpuff', 0xffdab9ff],
309309
['peachPuff', null],
310310
['PeachPuff', null],

packages/react-native-reanimated/src/Colors.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,8 @@ export function clampRGBA(RGBA: ParsedColorArray): void {
168168
}
169169
}
170170

171-
const names: Record<string, number> = {
172-
transparent: 0x00000000,
171+
const names: Record<string, number | undefined> = {
172+
transparent: undefined,
173173

174174
/* spell-checker: disable */
175175
// http://www.w3.org/TR/css3-color/#svg-color
@@ -364,7 +364,7 @@ export const DynamicColorIOSProperties = [
364364
'highContrastDark',
365365
] as const;
366366

367-
export function normalizeColor(color: unknown): number | null {
367+
export function normalizeColor(color: unknown): number | null | undefined {
368368
'worklet';
369369

370370
if (typeof color === 'number') {
@@ -385,7 +385,7 @@ export function normalizeColor(color: unknown): number | null {
385385
return Number.parseInt(match[1] + 'ff', 16) >>> 0;
386386
}
387387

388-
if (names[color] !== undefined) {
388+
if (color in names) {
389389
return names[color];
390390
}
391391

@@ -538,8 +538,8 @@ export const rgbaColor = (
538538
alpha = 1
539539
): number | string => {
540540
'worklet';
541-
// Replace tiny values like 1.234e-11 with 0:
542-
const safeAlpha = alpha < 0.001 ? 0 : alpha;
541+
// Round alpha to 3 decimal places to avoid floating point precision issues
542+
const safeAlpha = Math.round(alpha * 1000) / 1000;
543543
return `rgba(${r}, ${g}, ${b}, ${safeAlpha})`;
544544
};
545545

@@ -646,12 +646,9 @@ export function processColorInitially(
646646
colorNumber = color;
647647
} else {
648648
const normalizedColor = normalizeColor(color);
649-
if (normalizedColor === null || normalizedColor === undefined) {
650-
return undefined;
651-
}
652649

653650
if (typeof normalizedColor !== 'number') {
654-
return null;
651+
return normalizedColor;
655652
}
656653

657654
colorNumber = normalizedColor;

packages/react-native-reanimated/src/common/processors/colors.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,9 @@ function isDynamicColorObject(value: any): boolean {
6262

6363
export function processColor(color: unknown): number | null | undefined {
6464
let normalizedColor = processColorInitially(color);
65-
if (normalizedColor === null || normalizedColor === undefined) {
66-
return undefined;
67-
}
6865

6966
if (typeof normalizedColor !== 'number') {
70-
return null;
67+
return normalizedColor;
7168
}
7269

7370
if (IS_ANDROID) {

0 commit comments

Comments
 (0)