Skip to content

Commit

Permalink
Add JSProps on Fabric (#5009)
Browse files Browse the repository at this point in the history
## Summary

This pull request adds support for JS props in Fabric. The
implementation is available in the common codebase, so there is no
platform-specific code required. This approach doesn't rely on events
since, based on my research, using events would require messy
workarounds with React Native.
Same thing on Paper:
#4821

## Test plan

<details>
<summary>code</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 and Aleksandra Cynk committed Nov 24, 2023
1 parent 7bdbb56 commit baa663f
Show file tree
Hide file tree
Showing 7 changed files with 215 additions and 103 deletions.
53 changes: 49 additions & 4 deletions Common/cpp/NativeModules/NativeReanimatedModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "ShadowTreeCloner.h"
#endif

#include "CollectionUtils.h"
#include "EventHandlerRegistry.h"
#include "FeaturesConfig.h"
#include "JSScheduler.h"
Expand Down Expand Up @@ -282,11 +283,16 @@ jsi::Value NativeReanimatedModule::configureProps(
const jsi::Value &uiProps,
const jsi::Value &nativeProps) {
#ifdef RCT_NEW_ARCH_ENABLED
(void)uiProps; // unused variable on Fabric
jsi::Array array = nativeProps.asObject(rt).asArray(rt);
for (size_t i = 0; i < array.size(rt); ++i) {
std::string name = array.getValueAtIndex(rt, i).asString(rt).utf8(rt);
auto uiPropsArray = uiProps.asObject(rt).asArray(rt);
for (size_t i = 0; i < uiPropsArray.size(rt); ++i) {
auto name = uiPropsArray.getValueAtIndex(rt, i).asString(rt).utf8(rt);
animatablePropNames_.insert(name);
}
auto nativePropsArray = nativeProps.asObject(rt).asArray(rt);
for (size_t i = 0; i < nativePropsArray.size(rt); ++i) {
auto name = nativePropsArray.getValueAtIndex(rt, i).asString(rt).utf8(rt);
nativePropNames_.insert(name);
animatablePropNames_.insert(name);
}
#else
configurePropsPlatformFunction_(rt, uiProps, nativeProps);
Expand Down Expand Up @@ -393,6 +399,29 @@ bool NativeReanimatedModule::isThereAnyLayoutProp(
}
return false;
}

jsi::Value NativeReanimatedModule::filterNonAnimatableProps(
jsi::Runtime &rt,
const jsi::Value &props) {
jsi::Object nonAnimatableProps(rt);
bool hasAnyNonAnimatableProp = false;
const jsi::Object &propsObject = props.asObject(rt);
const jsi::Array &propNames = propsObject.getPropertyNames(rt);
for (size_t i = 0; i < propNames.size(rt); ++i) {
const std::string &propName =
propNames.getValueAtIndex(rt, i).asString(rt).utf8(rt);
if (!collection::contains(animatablePropNames_, propName)) {
hasAnyNonAnimatableProp = true;
const auto &propNameStr = propName.c_str();
const jsi::Value &propValue = propsObject.getProperty(rt, propNameStr);
nonAnimatableProps.setProperty(rt, propNameStr, propValue);
}
}
if (!hasAnyNonAnimatableProp) {
return jsi::Value::undefined();
}
return nonAnimatableProps;
}
#endif // RCT_NEW_ARCH_ENABLED

bool NativeReanimatedModule::handleEvent(
Expand Down Expand Up @@ -494,6 +523,22 @@ void NativeReanimatedModule::performOperations() {
}
}

for (const auto &[shadowNode, props] : copiedOperationsQueue) {
const jsi::Value &nonAnimatableProps = filterNonAnimatableProps(rt, *props);
if (nonAnimatableProps.isUndefined()) {
continue;
}
Tag viewTag = shadowNode->getTag();
jsi::Value maybeJSPropsUpdater =
rt.global().getProperty(rt, "updateJSProps");
assert(
maybeJSPropsUpdater.isObject() &&
"[Reanimated] `updateJSProps` not found");
jsi::Function jsPropsUpdater =
maybeJSPropsUpdater.asObject(rt).asFunction(rt);
jsPropsUpdater.call(rt, viewTag, nonAnimatableProps);
}

bool hasLayoutUpdates = false;
for (const auto &[shadowNode, props] : copiedOperationsQueue) {
if (isThereAnyLayoutProp(rt, props->asObject(rt))) {
Expand Down
6 changes: 5 additions & 1 deletion Common/cpp/NativeModules/NativeReanimatedModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ class NativeReanimatedModule : public NativeReanimatedModuleSpec {

#ifdef RCT_NEW_ARCH_ENABLED
bool isThereAnyLayoutProp(jsi::Runtime &rt, const jsi::Object &props);
jsi::Value filterNonAnimatableProps(
jsi::Runtime &rt,
const jsi::Value &props);
#endif // RCT_NEW_ARCH_ENABLED

const std::shared_ptr<MessageQueueThread> jsQueue_;
Expand All @@ -184,7 +187,8 @@ class NativeReanimatedModule : public NativeReanimatedModuleSpec {
const SynchronouslyUpdateUIPropsFunction synchronouslyUpdateUIPropsFunction_;

std::unordered_set<std::string> nativePropNames_; // filled by configureProps

std::unordered_set<std::string>
animatablePropNames_; // filled by configureProps
std::shared_ptr<UIManager> uiManager_;

// After app reload, surfaceId on iOS is still 1 but on Android it's 11.
Expand Down
92 changes: 0 additions & 92 deletions src/createAnimatedComponent/JSPropUpdater.ts

This file was deleted.

154 changes: 154 additions & 0 deletions src/createAnimatedComponent/JSPropsUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use strict';
import {
NativeEventEmitter,
NativeModules,
findNodeHandle,
} from 'react-native';
import { shouldBeUseWeb } from '../reanimated2/PlatformChecker';
import type { StyleProps } from '../reanimated2';
import { runOnJS, runOnUIImmediately } from '../reanimated2/threads';
import type {
AnimatedComponentProps,
IAnimatedComponentInternal,
IJSPropsUpdater,
InitialComponentProps,
} from './commonTypes';

interface ListenerData {
viewTag: number;
props: StyleProps;
}

const SHOULD_BE_USE_WEB = shouldBeUseWeb();

class JSPropsUpdaterPaper implements IJSPropsUpdater {
private static _tagToComponentMapping = new Map();
private _reanimatedEventEmitter: NativeEventEmitter;

constructor() {
this._reanimatedEventEmitter = new NativeEventEmitter(
NativeModules.ReanimatedModule
);
}

public addOnJSPropsChangeListener(
animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
const viewTag = findNodeHandle(animatedComponent);
JSPropsUpdaterPaper._tagToComponentMapping.set(viewTag, animatedComponent);
if (JSPropsUpdaterPaper._tagToComponentMapping.size === 1) {
const listener = (data: ListenerData) => {
const component = JSPropsUpdaterPaper._tagToComponentMapping.get(
data.viewTag
);
component?._updateFromNative(data.props);
};
this._reanimatedEventEmitter.addListener(
'onReanimatedPropsChange',
listener
);
}
}

public removeOnJSPropsChangeListener(
animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
const viewTag = findNodeHandle(animatedComponent);
JSPropsUpdaterPaper._tagToComponentMapping.delete(viewTag);
if (JSPropsUpdaterPaper._tagToComponentMapping.size === 0) {
this._reanimatedEventEmitter.removeAllListeners(
'onReanimatedPropsChange'
);
}
}
}

class JSPropsUpdaterFabric implements IJSPropsUpdater {
private static _tagToComponentMapping = new Map();
private static isInitialized = false;

constructor() {
if (!JSPropsUpdaterFabric.isInitialized) {
const updater = (viewTag: number, props: unknown) => {
const component =
JSPropsUpdaterFabric._tagToComponentMapping.get(viewTag);
component?._updateFromNative(props);
};
runOnUIImmediately(() => {
'worklet';
global.updateJSProps = (viewTag: number, props: unknown) => {
runOnJS(updater)(viewTag, props);
};
})();
JSPropsUpdaterFabric.isInitialized = true;
}
}

public addOnJSPropsChangeListener(
animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
if (!JSPropsUpdaterFabric.isInitialized) {
return;
}
const viewTag = findNodeHandle(animatedComponent);
JSPropsUpdaterFabric._tagToComponentMapping.set(viewTag, animatedComponent);
}

public removeOnJSPropsChangeListener(
animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
if (!JSPropsUpdaterFabric.isInitialized) {
return;
}
const viewTag = findNodeHandle(animatedComponent);
JSPropsUpdaterFabric._tagToComponentMapping.delete(viewTag);
}
}

class JSPropsUpdaterWeb implements IJSPropsUpdater {
public addOnJSPropsChangeListener(
_animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
// noop
}

public removeOnJSPropsChangeListener(
_animatedComponent: React.Component<
AnimatedComponentProps<InitialComponentProps>
> &
IAnimatedComponentInternal
) {
// noop
}
}

type JSPropsUpdaterOptions =
| typeof JSPropsUpdaterWeb
| typeof JSPropsUpdaterFabric
| typeof JSPropsUpdaterPaper;

let JSPropsUpdater: JSPropsUpdaterOptions;
if (SHOULD_BE_USE_WEB) {
JSPropsUpdater = JSPropsUpdaterWeb;
} else if (global._IS_FABRIC) {
JSPropsUpdater = JSPropsUpdaterFabric;
} else {
JSPropsUpdater = JSPropsUpdaterPaper;
}

export default JSPropsUpdater;
4 changes: 2 additions & 2 deletions src/createAnimatedComponent/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface IPropsFilter {
) => Record<string, unknown>;
}

export interface IJSPropUpdater {
export interface IJSPropsUpdater {
addOnJSPropsChangeListener(
animatedComponent: React.Component<unknown, unknown> &
IAnimatedComponentInternal
Expand Down Expand Up @@ -91,7 +91,7 @@ export interface IAnimatedComponentInternal {
animatedStyle: { value: StyleProps };
_component: AnimatedComponentRef | HTMLElement | null;
_sharedElementTransition: SharedTransition | null;
_JSPropUpdater: IJSPropUpdater;
_jsPropsUpdater: IJSPropsUpdater;
_InlinePropManager: IInlinePropManager;
_PropsFilter: IPropsFilter;
_viewInfo?: ViewInfo;
Expand Down
Loading

0 comments on commit baa663f

Please sign in to comment.