Skip to content

Commit

Permalink
Fix animated UI Props on Web (#5169)
Browse files Browse the repository at this point in the history
## Summary

This PR fixes regression introduced in
#4977.
Some animated props (for example UI props) required to be updated
directly as component prop - `setNativeProps(props)`, but all the rest
of them still required to be updated as a component style -
`setNativeProps({style: props})`.

| before | after |
| --- | --- |
| <video
src="https://github.com/software-mansion/react-native-reanimated/assets/36106620/60eb728e-1724-4826-9174-0d7b603595b5"
/> | <video
src="https://github.com/software-mansion/react-native-reanimated/assets/36106620/9ac9aa45-6827-4572-b268-f2510c2e5503"
/> |

## Test plan

<details>
<summary>AnimatedProps - UIProps - #4909</summary>

```js
import { Button, Text, View } from 'react-native';
import Animated, {
  useSharedValue,
  useAnimatedProps,
} from 'react-native-reanimated';
Animated.addWhitelistedNativeProps({
  accessibilityRole: true,
});
Animated.addWhitelistedUIProps({
  accessibilityRole: true,
});

export default function App() {
  const sharedRole = useSharedValue('initial');
  const animatedProps = useAnimatedProps(() => {
    console.debug('this gets trigered correctly', sharedRole.value);
    return {
      accessibilityRole: sharedRole.value
    };
  }, [sharedRole]);

  return (
    <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Animated.View
        animatedProps={animatedProps}
        nativeID="find-me-in-dev-tools">
        <Text>some lorem ipsum</Text>
      </Animated.View>
      <Button
        title="ToggleRole"
        onPress={() => {
          if (sharedRole.value == 'New Value') {
            sharedRole.value = 'New Value2';
          } else {
            sharedRole.value = 'New Value';
          }
        }}
      />
    </View>
  );
}


```

</details>


<details>
<summary>AnimatedProps</summary>

```js
import { TextInput } from 'react-native';
import React, { useEffect } from 'react';
import Svg, {
  Path,
  Circle,
  G,
} from 'react-native-svg';
import Animated, {
  runOnJS,
  useSharedValue,
  useDerivedValue,
  useAnimatedProps,
  useAnimatedGestureHandler,
  interpolate,
} from 'react-native-reanimated';
import {PanGestureHandler} from 'react-native-gesture-handler';

const BORDER_WIDTH = 25;
const DIAL_RADIUS = 22.5;

export const {PI} = Math;
export const TAU = 2 * PI;

const AnimatedPath = Animated.createAnimatedComponent(Path);
const AnimatedG = Animated.createAnimatedComponent(G);
const AnimatedInput = Animated.createAnimatedComponent(TextInput);

const polarToCartesian = (
  angle: number,
  radius: number,
  {x, y}: {x: number; y: number},
) => {
  'worklet';
  const a = ((angle - 90) * Math.PI) / 180.0;
  return {x: x + radius * Math.cos(a), y: y + radius * Math.sin(a)};
};

const cartesianToPolar = (
  x: number,
  y: number,
  {x: cx, y: cy}: {x: number; y: number},
  step = 1,
) => {
  'worklet';

  const value =
    Math.atan((y - cy) / (x - cx)) / (Math.PI / 180) + (x > cx ? 90 : 270);

  return Math.round(value * (1 / step)) / (1 / step);
};

const unMix = (value: number, x: number, y: number) => {
  'worklet';
  return (value - x) / (y - x);
};

type Props = {
  width: number;
  height: number;
  fillColor: string[];
  value: number;
  meterColor: string;
  min?: number;
  max?: number;
  onValueChange: (value: any) => void;
  children: (
    props: Partial<{defaultValue: string; text: string}>,
  ) => React.ReactNode;
  step?: number;
  decimals?: number;
};

const CircleSlider = (props: Props) => {
  const {
    width,
    height,
    value,
    meterColor,
    children,
    min,
    max,
    step = 1,
    decimals,
  } = props;
  const smallestSide = Math.min(width, height);

  const cx = width / 2;
  const cy = height / 2;
  const r = (smallestSide / 2) * 0.85;

  const start = useSharedValue(0);
  const end = useSharedValue(unMix(value, min! / 360, max! / 360));

  useEffect(() => {
    end.value = unMix(value, min! / 360, max! / 360);
  }, [value, end, max, min]);

  const startPos = useDerivedValue(() =>
    polarToCartesian(start.value, r, {x: cx, y: cy}),
  );

  const endPos = useDerivedValue(() =>
    polarToCartesian(end.value, r, {x: cx, y: cy}),
  );

  const animatedPath = useAnimatedProps(() => {
    const p1 = startPos.value;
    const p2 = endPos.value;
    return {
      d: `M${p1.x} ${p1.y} A ${r} ${r} 0 ${end.value > 180 ? 1 : 0} 1 ${p2.x} ${
        p2.y
      }`,
    };
  });

  const animatedCircle = useAnimatedProps(() => {
    const p2 = endPos.value;
    return {
      x: p2.x - 7.5,
      y: p2.y - 7.5,
    };
  });

  const animatedChildrenProps = useAnimatedProps(() => {
    const decimalCount = (num: number) => {
      if (decimals) {
        return decimals;
      }
      const numStr = String(num);
      if (numStr.includes('.')) {
        return numStr.split('.')[1].length;
      }
      return 0;
    };

    const value = interpolate(end.value, [min! / 360, max! / 360], [min! / 360, max! / 360]);

    return {
      defaultValue: `${value.toFixed(decimalCount(step))}`,
      text: `${value.toFixed(decimalCount(step))}`,
    };
  });

  const gestureHandler = useAnimatedGestureHandler({
    onActive: ({x, y}: {x: number; y: number}, ctx: any) => {
      const value = cartesianToPolar(x, y, {x: cx, y: cy}, step);

      ctx.value = interpolate(value, [min! / 360, max! / 360], [min! / 360, max! / 360]);
      end.value = value;
    },
    onFinish: (_, ctx) => {
      runOnJS(props.onValueChange)(ctx.value);
    },
  });
  
  return (
    <PanGestureHandler onGestureEvent={gestureHandler}>
      <Animated.View>
        <Svg width={width} height={height}>
          <Circle cx={cx} cy={cy} r={r + BORDER_WIDTH / 2 - 1} fill="blue" />
          <Circle
            cx={cx}
            cy={cy}
            r={r}
            strokeWidth={BORDER_WIDTH}
            fill="url(#fill)"
            stroke="rgba(255, 255, 525, 0.2)"
          />
          <AnimatedPath
            stroke={meterColor}
            strokeWidth={BORDER_WIDTH}
            fill="none"
            animatedProps={animatedPath}
          />
          <AnimatedG animatedProps={animatedCircle} onPress={() => {}}>
            <Circle cx={7.5} cy={7.5} r={DIAL_RADIUS} fill={meterColor} />
          </AnimatedG>
        </Svg>
        {children && children(animatedChildrenProps)}
      </Animated.View>
    </PanGestureHandler>
  );
};

CircleSlider.defaultProps = {
  width: 300,
  height: 300,
  fillColor: ['#fff'],
  meterColor: '#fff',
  min: 0,
  max: 359,
  step: 1,
  onValueChange: (x: any) => x,
};

export default function EmptyExample(): JSX.Element {
  const handleChange = value => console.log(value);
  console.log('EmptyExample');
  return (
    <CircleSlider
      width={325}
      height={325}
      value={0}
      meterColor={'#ffffff'}
      onValueChange={handleChange}>
      {animatedProps => (
        <AnimatedInput
          keyboardType="numeric"
          maxLength={3}
          selectTextOnFocus={false}
          animatedProps={animatedProps}
        />
      )}
    </CircleSlider>
  );
}

```

</details>
  • Loading branch information
piaskowyk authored Oct 3, 2023
1 parent 062f36a commit 66be813
Show file tree
Hide file tree
Showing 3 changed files with 161 additions and 133 deletions.
153 changes: 22 additions & 131 deletions src/ConfigHelper.ts
Original file line number Diff line number Diff line change
@@ -1,150 +1,41 @@
'use strict';
import { PropsAllowlists } from './propsAllowlists';
import { configureProps as jsiConfigureProps } from './reanimated2/core';

/**
* Styles allowed to be direcly updated in UI thread
*/
let UI_THREAD_PROPS_WHITELIST: Record<string, boolean> = {
opacity: true,
transform: true,
/* colors */
backgroundColor: true,
borderRightColor: true,
borderBottomColor: true,
borderColor: true,
borderEndColor: true,
borderLeftColor: true,
borderStartColor: true,
borderTopColor: true,
/* ios styles */
shadowOpacity: true,
shadowRadius: true,
/* legacy android transform properties */
scaleX: true,
scaleY: true,
translateX: true,
translateY: true,
};

/**
* Whitelist of view props that can be updated in native thread via UIManagerModule
*/
let NATIVE_THREAD_PROPS_WHITELIST: Record<string, boolean> = {
borderBottomWidth: true,
borderEndWidth: true,
borderLeftWidth: true,
borderRightWidth: true,
borderStartWidth: true,
borderTopWidth: true,
borderWidth: true,
bottom: true,
flex: true,
flexGrow: true,
flexShrink: true,
height: true,
left: true,
margin: true,
marginBottom: true,
marginEnd: true,
marginHorizontal: true,
marginLeft: true,
marginRight: true,
marginStart: true,
marginTop: true,
marginVertical: true,
maxHeight: true,
maxWidth: true,
minHeight: true,
minWidth: true,
padding: true,
paddingBottom: true,
paddingEnd: true,
paddingHorizontal: true,
paddingLeft: true,
paddingRight: true,
paddingStart: true,
paddingTop: true,
paddingVertical: true,
right: true,
start: true,
top: true,
width: true,
zIndex: true,
borderBottomEndRadius: true,
borderBottomLeftRadius: true,
borderBottomRightRadius: true,
borderBottomStartRadius: true,
borderRadius: true,
borderTopEndRadius: true,
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderTopStartRadius: true,
elevation: true,
fontSize: true,
lineHeight: true,
textShadowRadius: true,
textShadowOffset: true,
letterSpacing: true,
aspectRatio: true,
columnGap: true, // iOS only
end: true, // number or string
flexBasis: true, // number or string
gap: true,
rowGap: true,
/* strings */
display: true,
backfaceVisibility: true,
overflow: true,
resizeMode: true,
fontStyle: true,
fontWeight: true,
textAlign: true,
textDecorationLine: true,
fontFamily: true,
textAlignVertical: true,
fontVariant: true,
textDecorationStyle: true,
textTransform: true,
writingDirection: true,
alignContent: true,
alignItems: true,
alignSelf: true,
direction: true, // iOS only
flexDirection: true,
flexWrap: true,
justifyContent: true,
position: true,
/* text color */
color: true,
tintColor: true,
shadowColor: true,
placeholderTextColor: true,
};

function configureProps(): void {
jsiConfigureProps(
Object.keys(UI_THREAD_PROPS_WHITELIST),
Object.keys(NATIVE_THREAD_PROPS_WHITELIST)
Object.keys(PropsAllowlists.UI_THREAD_PROPS_WHITELIST),
Object.keys(PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST)
);
}

export function addWhitelistedNativeProps(
props: Record<string, boolean>
): void {
const oldSize = Object.keys(NATIVE_THREAD_PROPS_WHITELIST).length;
NATIVE_THREAD_PROPS_WHITELIST = {
...NATIVE_THREAD_PROPS_WHITELIST,
const oldSize = Object.keys(
PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST
).length;
PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST = {
...PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST,
...props,
};
if (oldSize !== Object.keys(NATIVE_THREAD_PROPS_WHITELIST).length) {
if (
oldSize !==
Object.keys(PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST).length
) {
configureProps();
}
}

export function addWhitelistedUIProps(props: Record<string, boolean>): void {
const oldSize = Object.keys(UI_THREAD_PROPS_WHITELIST).length;
UI_THREAD_PROPS_WHITELIST = { ...UI_THREAD_PROPS_WHITELIST, ...props };
if (oldSize !== Object.keys(UI_THREAD_PROPS_WHITELIST).length) {
const oldSize = Object.keys(PropsAllowlists.UI_THREAD_PROPS_WHITELIST).length;
PropsAllowlists.UI_THREAD_PROPS_WHITELIST = {
...PropsAllowlists.UI_THREAD_PROPS_WHITELIST,
...props,
};
if (
oldSize !== Object.keys(PropsAllowlists.UI_THREAD_PROPS_WHITELIST).length
) {
configureProps();
}
}
Expand All @@ -171,8 +62,8 @@ export function adaptViewConfig(viewConfig: ViewConfig): void {
// we don't want to add native props as they affect layout
// we also skip props which repeat here
if (
!(key in NATIVE_THREAD_PROPS_WHITELIST) &&
!(key in UI_THREAD_PROPS_WHITELIST)
!(key in PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST) &&
!(key in PropsAllowlists.UI_THREAD_PROPS_WHITELIST)
) {
propsToAdd[key] = true;
}
Expand Down
125 changes: 125 additions & 0 deletions src/propsAllowlists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
type AllowlistsHolder = {
UI_THREAD_PROPS_WHITELIST: Record<string, boolean>;
NATIVE_THREAD_PROPS_WHITELIST: Record<string, boolean>;
};

export const PropsAllowlists: AllowlistsHolder = {
/**
* Styles allowed to be direcly updated in UI thread
*/
UI_THREAD_PROPS_WHITELIST: {
opacity: true,
transform: true,
/* colors */
backgroundColor: true,
borderRightColor: true,
borderBottomColor: true,
borderColor: true,
borderEndColor: true,
borderLeftColor: true,
borderStartColor: true,
borderTopColor: true,
/* ios styles */
shadowOpacity: true,
shadowRadius: true,
/* legacy android transform properties */
scaleX: true,
scaleY: true,
translateX: true,
translateY: true,
},
/**
* Whitelist of view props that can be updated in native thread via UIManagerModule
*/
NATIVE_THREAD_PROPS_WHITELIST: {
borderBottomWidth: true,
borderEndWidth: true,
borderLeftWidth: true,
borderRightWidth: true,
borderStartWidth: true,
borderTopWidth: true,
borderWidth: true,
bottom: true,
flex: true,
flexGrow: true,
flexShrink: true,
height: true,
left: true,
margin: true,
marginBottom: true,
marginEnd: true,
marginHorizontal: true,
marginLeft: true,
marginRight: true,
marginStart: true,
marginTop: true,
marginVertical: true,
maxHeight: true,
maxWidth: true,
minHeight: true,
minWidth: true,
padding: true,
paddingBottom: true,
paddingEnd: true,
paddingHorizontal: true,
paddingLeft: true,
paddingRight: true,
paddingStart: true,
paddingTop: true,
paddingVertical: true,
right: true,
start: true,
top: true,
width: true,
zIndex: true,
borderBottomEndRadius: true,
borderBottomLeftRadius: true,
borderBottomRightRadius: true,
borderBottomStartRadius: true,
borderRadius: true,
borderTopEndRadius: true,
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderTopStartRadius: true,
elevation: true,
fontSize: true,
lineHeight: true,
textShadowRadius: true,
textShadowOffset: true,
letterSpacing: true,
aspectRatio: true,
columnGap: true, // iOS only
end: true, // number or string
flexBasis: true, // number or string
gap: true,
rowGap: true,
/* strings */
display: true,
backfaceVisibility: true,
overflow: true,
resizeMode: true,
fontStyle: true,
fontWeight: true,
textAlign: true,
textDecorationLine: true,
fontFamily: true,
textAlignVertical: true,
fontVariant: true,
textDecorationStyle: true,
textTransform: true,
writingDirection: true,
alignContent: true,
alignItems: true,
alignSelf: true,
direction: true, // iOS only
flexDirection: true,
flexWrap: true,
justifyContent: true,
position: true,
/* text color */
color: true,
tintColor: true,
shadowColor: true,
placeholderTextColor: true,
},
};
16 changes: 14 additions & 2 deletions src/reanimated2/js-reanimated/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import JSReanimated from './JSReanimated';
import type { StyleProps } from '../commonTypes';
import type { AnimatedStyle } from '../helperTypes';
import { isWeb } from '../PlatformChecker';
import { PropsAllowlists } from '../../propsAllowlists';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createReactDOMStyle: (style: any) => any;
Expand Down Expand Up @@ -113,8 +114,15 @@ const setNativeProps = (
isAnimatedProps?: boolean
): void => {
if (isAnimatedProps) {
component.setNativeProps?.(newProps);
return;
const uiProps: Record<string, unknown> = {};
for (const key in newProps) {
if (isNativeProp(key)) {
uiProps[key] = newProps[key];
}
}
// Only update UI props directly on the component,
// other props can be updated as standard style props.
component.setNativeProps?.(uiProps);
}

const previousStyle = component.previousStyle ? component.previousStyle : {};
Expand Down Expand Up @@ -162,4 +170,8 @@ const updatePropsDOM = (
}
};

function isNativeProp(propName: string): boolean {
return !!PropsAllowlists.NATIVE_THREAD_PROPS_WHITELIST[propName];
}

export default reanimatedJS;

0 comments on commit 66be813

Please sign in to comment.