diff --git a/apps/common-app/src/apps/css/examples/transitions/routes.ts b/apps/common-app/src/apps/css/examples/transitions/routes.ts index 38b2b1a9c584..75905bd66118 100644 --- a/apps/common-app/src/apps/css/examples/transitions/routes.ts +++ b/apps/common-app/src/apps/css/examples/transitions/routes.ts @@ -100,6 +100,10 @@ const routes = { name: ':active', Component: pseudoSelectors.Active, }, + PseudoActiveBlocksRender: { + name: ':active blocks render transition', + Component: pseudoSelectors.ActiveBlocksRender, + }, PseudoActiveDeepest: { name: ':active-deepest', Component: pseudoSelectors.ActiveDeepest, @@ -124,9 +128,18 @@ const routes = { name: 'Arbitrary web selectors', Component: pseudoSelectors.ArbitraryWebSelectors, }, - PseudoShowcase: { + Showcase: { name: 'Showcase', - Component: pseudoSelectors.Showcase, + routes: { + PseudoPlanets: { + name: 'Planets', + Component: pseudoSelectors.Planets, + }, + PseudoForm: { + name: 'Form', + Component: pseudoSelectors.Form, + }, + }, }, }, }, diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx new file mode 100644 index 000000000000..2df677291b45 --- /dev/null +++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/ActiveBlocksRender.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { StyleSheet } from 'react-native'; +import Animated from 'react-native-reanimated'; + +import { + Screen, + Scroll, + Section, + VerticalExampleCard, +} from '@/apps/css/components'; +import { colors, radius, sizes, spacing } from '@/theme'; + +export default function ActiveBlocksRender() { + const [phase, setPhase] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setPhase((prev) => (prev + 1) % 2); + }, 1000); + return () => clearInterval(interval); + }, []); + + const renderColor = phase === 0 ? colors.danger : colors.primaryLight; + + return ( + + +
+ true} +/>`} + collapsedCode={`backgroundColor: { + default: renderColor, + ':active': colors.primaryDark, +},`}> + true} + /> + +
+
+
+ ); +} + +const styles = StyleSheet.create({ + box: { + borderRadius: radius.md, + height: sizes.md, + width: sizes.md, + }, + content: { + gap: spacing.xs, + }, +}); diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx similarity index 99% rename from apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx rename to apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx index f5a6eee3c27f..0234bd293cce 100644 --- a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Showcase.tsx +++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Form.tsx @@ -139,7 +139,7 @@ function Card({ ); } -export default function Showcase() { +export default function Form() { return ( diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx new file mode 100644 index 000000000000..67e548ddcfb3 --- /dev/null +++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/Planets.tsx @@ -0,0 +1,151 @@ +import { StyleSheet, Text } from 'react-native'; +import Animated, { css } from 'react-native-reanimated'; + +import { + Screen, + Scroll, + Section, + VerticalExampleCard, +} from '@/apps/css/components'; +import { colors, spacing } from '@/theme'; + +const ORBIT_RX = 90; +const ORBIT_RY = 40; +const ORBIT_DURATION = '12s'; +const ORBIT_STEPS = 36; + +const PLANETS = [ + { color: colors.danger, name: 'Mars', size: 44 }, + { color: colors.primary, name: 'Venus', size: 60 }, + { color: colors.primaryDark, name: 'Jupiter', size: 78 }, +]; + +const MAX_PLANET_SIZE = Math.max(...PLANETS.map((planet) => planet.size)); +const PLANE_WIDTH = 2 * ORBIT_RX + MAX_PLANET_SIZE; +const PLANE_HEIGHT = 2 * ORBIT_RY + MAX_PLANET_SIZE; +const CENTER_X = PLANE_WIDTH / 2; +const CENTER_Y = PLANE_HEIGHT / 2; + +function makeOrbit(offsetDeg: number, size: number) { + const frames: Record = {}; + for (let step = 0; step <= ORBIT_STEPS; step++) { + const percent = ((step / ORBIT_STEPS) * 100).toFixed(4); + const angle = ((offsetDeg + (360 * step) / ORBIT_STEPS) * Math.PI) / 180; + frames[`${percent}%`] = { + left: CENTER_X + ORBIT_RX * Math.cos(angle) - size / 2, + top: CENTER_Y + ORBIT_RY * Math.sin(angle) - size / 2, + }; + } + return css.keyframes(frames); +} + +const ORBITS = PLANETS.map((planet, index) => + makeOrbit((360 / PLANETS.length) * index, planet.size) +); + +export default function Planets() { + return ( + + +
+ true}> + {name} +`} + collapsedCode={`opacity: { + default: 0.02, + ':active': 1, +},`}> + + + {PLANETS.map((planet, index) => ( + + true}> + {planet.name} + + + ))} + + + +
+
+
+ ); +} + +const styles = StyleSheet.create({ + content: { + gap: spacing.xs, + }, + name: { + color: colors.white, + fontSize: 12, + fontWeight: '600', + textAlign: 'center', + }, + nameWrapper: { + alignItems: 'center', + bottom: 0, + justifyContent: 'center', + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + orbitPlane: { + height: PLANE_HEIGHT, + width: PLANE_WIDTH, + }, + orbiter: { + position: 'absolute', + shadowOffset: { height: 0, width: 0 }, + }, + stage: { + alignItems: 'center', + height: PLANE_HEIGHT + spacing.xl, + justifyContent: 'center', + }, +}); diff --git a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts index 7f50d2207ef9..6c3c8bb2ac86 100644 --- a/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts +++ b/apps/common-app/src/apps/css/examples/transitions/screens/pseudoSelectors/index.ts @@ -1,21 +1,25 @@ import Active from './Active'; +import ActiveBlocksRender from './ActiveBlocksRender'; import ActiveDeepest from './ActiveDeepest'; import ArbitraryWebSelectors from './ArbitraryWebSelectors'; import Focus from './Focus'; import FocusWithin from './FocusWithin'; +import Form from './Form'; import Hover from './Hover'; import HoverWithLoop from './HoverWithLoop'; import PerStateTransitionConfig from './PerStateTransitionConfig'; -import Showcase from './Showcase'; +import Planets from './Planets'; export default { Active, + ActiveBlocksRender, ActiveDeepest, ArbitraryWebSelectors, Focus, FocusWithin, + Form, Hover, HoverWithLoop, PerStateTransitionConfig, - Showcase, + Planets, }; diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp index 2e8022088abc..c26a7b9c96e0 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.cpp @@ -36,6 +36,12 @@ TransitionProperties CSSTransition::getProperties() const { folly::dynamic CSSTransition::run(jsi::Runtime &rt, CSSTransitionConfig &&config, const folly::dynamic &lastUpdates) { const auto timestamp = loop_->resolveTimestamp(); + for (const auto &propertyName : pseudoLockedProperties_) { + config.changedPropertiesSettings.erase(propertyName); + config.changedProperties.erase(propertyName); + std::erase(config.removedProperties, propertyName); + } + // CSSTransition owns routing: platform-routed props run immediately on the platform // transition; the loop-routed remainder is applied to the loop transition below. auto processed = platformTransitionProxy_->processConfig(std::move(config), routing_); @@ -100,6 +106,10 @@ folly::dynamic CSSTransition::computeCurrentLoopStyle() { return loopTransition_->computeCurrentStyle(shadowNode_); } +void CSSTransition::setPseudoLockedProperties(TransitionProperties properties) { + pseudoLockedProperties_ = std::move(properties); +} + void CSSTransition::cancel() { if (loopTransition_) { loop_->remove(loopTransition_); diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h index bee13a233e7c..3ed910835f2b 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/core/transition/CSSTransition.h @@ -55,6 +55,8 @@ class CSSTransition { folly::dynamic run(const PropertyValueDynamicDiffsMap &propertyDiffs, const folly::dynamic &lastUpdates); void cancel(); + void setPseudoLockedProperties(TransitionProperties properties); + private: const std::shared_ptr shadowNode_; const std::shared_ptr viewStylesRepository_; @@ -63,6 +65,7 @@ class CSSTransition { Observer &observer_; CSSTransitionRouting routing_; + TransitionProperties pseudoLockedProperties_; std::unique_ptr platformTransition_; std::shared_ptr loopTransition_; diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp index 6dc38cc4f3e8..d2374cbddacd 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.cpp @@ -39,6 +39,14 @@ void CSSTransitionsRegistry::run( recordInitialUpdate(transition, initialUpdate); } +void CSSTransitionsRegistry::setPseudoLockedProperties(const Tag viewTag, const TransitionProperties &properties) { + react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread()); + const auto it = registry_.find(viewTag); + if (it != registry_.end()) { + it->second->setPseudoLockedProperties(properties); + } +} + void CSSTransitionsRegistry::flushUpdates(UpdatesBatch &updatesBatch) { react_native_assert(UpdatesRegistryManager::isLockedByCurrentThread()); const auto tags = std::exchange(updatedTags_, {}); diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h index f422641317a5..b9a74fc2ec53 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h +++ b/packages/react-native-reanimated/Common/cpp/reanimated/CSS/registries/CSSTransitionsRegistry.h @@ -28,6 +28,8 @@ class CSSTransitionsRegistry : public UpdatesRegistry { CSSTransitionConfig &&config); void run(const std::shared_ptr &shadowNode, const PropertyValueDynamicDiffsMap &propertyDiffs); + void setPseudoLockedProperties(Tag viewTag, const TransitionProperties &properties); + void flushUpdates(UpdatesBatch &updatesBatch); #if REACT_NATIVE_VERSION_MINOR >= 85 void flushUpdates(UpdatesBatchAnimatedProps &updatesBatch); diff --git a/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp b/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp index d68df6e4ccd7..0eb69a5cfe53 100644 --- a/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp +++ b/packages/react-native-reanimated/Common/cpp/reanimated/PseudoStyles/PseudoStylesRegistry.cpp @@ -55,17 +55,20 @@ void PseudoStylesRegistry::registerPseudoStyle( auto &entry = registry_[tag]; entry.shadowNode = shadowNode; + const bool isNewSelector = !entry.selectors.contains(selector); entry.selectors[selector] = {selectorStyle, defaultStyle}; entry.precomputedStyles = recomputeAllStyles(entry); - attachFn_( - tag, - selector, - [weakThis = std::weak_ptr(shared_from_this()), tag, selector](bool isActive) { - if (auto strongThis = weakThis.lock()) { - strongThis->onSelectorStateChanged(tag, selector, isActive); - } - }); + if (isNewSelector) { + attachFn_( + tag, + selector, + [weakThis = std::weak_ptr(shared_from_this()), tag, selector](bool isActive) { + if (auto strongThis = weakThis.lock()) { + strongThis->onSelectorStateChanged(tag, selector, isActive); + } + }); + } } void PseudoStylesRegistry::remove(Tag tag) { @@ -100,6 +103,19 @@ void PseudoStylesRegistry::onSelectorStateChanged(Tag tag, PseudoSelector select const auto &fromStyle = entry.precomputedStyles[oldMask]; const auto &toStyle = entry.precomputedStyles[entry.activeMask]; + css::TransitionProperties lockedProperties; + for (const auto &[sel, data] : entry.selectors) { + if (!(entry.activeMask & (1u << static_cast(sel)))) { + continue; + } + for (const auto &[propKey, val] : data.selectorStyle.items()) { + if (!val.isNull()) { + lockedProperties.insert(propKey.asString()); + } + } + } + cssTransitionsRegistry_->setPseudoLockedProperties(tag, lockedProperties); + css::PropertyValueDynamicDiffsMap valueChanges; for (const auto &[propKey, toVal] : toStyle.items()) { const auto propName = propKey.asString(); diff --git a/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts b/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts index 8fc9b62e6790..b7ff2df34f5d 100644 --- a/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts +++ b/packages/react-native-reanimated/src/css/native/managers/CSSPseudoStylesManager.ts @@ -47,17 +47,25 @@ export default class CSSPseudoStylesManager implements ICSSPseudoStylesManager { ) { return; } + const removedSelector = hasRemovedNativeSelector( + this.prevPseudoStylesBySelector, + pseudoStylesBySelector + ); + this.prevPseudoStylesBySelector = pseudoStylesBySelector; this.prevTransitionProperties = transitionProperties; - if (this.isRegistered) { - this.detach(); - } - if (!pseudoStylesBySelector) { + if (this.isRegistered) { + this.detach(); + } return; } + if (this.isRegistered && removedSelector) { + this.detach(); + } + const normalizedTransition = transitionProperties ? normalizeCSSTransitionProperties(transitionProperties) : null; @@ -140,6 +148,21 @@ export default class CSSPseudoStylesManager implements ICSSPseudoStylesManager { } } +function hasRemovedNativeSelector( + prev: PseudoStylesBySelector | null, + next: PseudoStylesBySelector | null +): boolean { + if (!prev) { + return false; + } + const nextKeys = next ? new Set(Object.keys(next)) : new Set(); + return Object.keys(prev).some( + (selector) => + NATIVE_PSEUDO_SELECTORS.has(selector as NativePseudoSelectorKey) && + !nextKeys.has(selector) + ); +} + function nullifyUndefinedValues(style: UnknownRecord): void { for (const key in style) { if (style[key] === undefined) { diff --git a/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts b/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts index ac02d2911bd4..73afafda9b74 100644 --- a/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts +++ b/packages/react-native-reanimated/src/css/native/managers/__tests__/CSSPseudoStylesManager.test.ts @@ -326,7 +326,7 @@ describe('CSSPseudoStylesManager', () => { expect(unregisterPseudoStyle).not.toHaveBeenCalled(); }); - test('detaches and re-registers when selector style changes', () => { + test('updates in place without detaching when only selector style changes', () => { pushStyle(manager, { opacity: { default: 0, ':hover': 1 }, }); @@ -336,7 +336,7 @@ describe('CSSPseudoStylesManager', () => { opacity: { default: 0, ':hover': 0.5 }, }); - expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag); + expect(unregisterPseudoStyle).not.toHaveBeenCalled(); expect(registerPseudoStyle).toHaveBeenCalledWith( shadowNodeWrapper, expect.objectContaining({ @@ -345,7 +345,7 @@ describe('CSSPseudoStylesManager', () => { ); }); - test('detaches and re-registers when only the transition changes', () => { + test('updates in place without detaching when only the transition changes', () => { pushStyle(manager, { opacity: { default: 0, ':hover': 1 }, transitionProperty: 'opacity', @@ -359,7 +359,7 @@ describe('CSSPseudoStylesManager', () => { transitionDuration: '500ms', }); - expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag); + expect(unregisterPseudoStyle).not.toHaveBeenCalled(); expect(registerPseudoStyle).toHaveBeenCalledWith( shadowNodeWrapper, expect.objectContaining({ @@ -369,6 +369,20 @@ describe('CSSPseudoStylesManager', () => { }) ); }); + + test('detaches and re-registers when a selector is removed', () => { + pushStyle(manager, { + opacity: { default: 0, ':hover': 1, ':active': 0.5 }, + }); + jest.clearAllMocks(); + + pushStyle(manager, { + opacity: { default: 0, ':hover': 1 }, + }); + + expect(unregisterPseudoStyle).toHaveBeenCalledWith(viewTag); + expect(registerPseudoStyle).toHaveBeenCalled(); + }); }); describe('detach (via removing pseudo styles)', () => {