react-native-tvos 0.87.0-0 → 0.87.1-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/Libraries/Animated/AnimatedEvent.js +1 -1
  2. package/Libraries/Animated/AnimatedImplementation.js +6 -2
  3. package/Libraries/Animated/nodes/AnimatedNode.js +1 -1
  4. package/Libraries/Animated/nodes/AnimatedValue.js +2 -2
  5. package/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent.js +2 -0
  6. package/Libraries/Components/ScrollView/ScrollView.d.ts +26 -0
  7. package/Libraries/Components/ScrollView/ScrollView.js +32 -0
  8. package/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +2 -0
  9. package/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +11 -0
  10. package/Libraries/Components/ScrollView/processScrollAnimationEasing.js +40 -0
  11. package/Libraries/Components/TV/TVFocusGuideView.js +31 -0
  12. package/Libraries/Components/TV/useFocusEnterLeave.js +112 -0
  13. package/Libraries/Core/ReactNativeVersion.js +1 -1
  14. package/README.md +17 -0
  15. package/React/Base/RCTVersion.m +1 -1
  16. package/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +30 -0
  17. package/ReactAndroid/gradle.properties +1 -1
  18. package/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +7 -3
  19. package/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +1 -1
  20. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +36 -13
  21. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +13 -0
  22. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +25 -3
  23. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +62 -0
  24. package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +13 -0
  25. package/ReactCommon/cxxreact/ReactNativeVersion.h +2 -2
  26. package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.cpp +26 -0
  27. package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.h +3 -0
  28. package/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +1 -1
  29. package/package.json +8 -8
  30. package/scripts/cocoapods/rndependencies.rb +31 -25
  31. package/scripts/replace-rncore-version.js +18 -2
  32. package/scripts/setup-apple-spm.js +28 -12
  33. package/scripts/spm/autolinking-plugins.js +80 -0
  34. package/scripts/spm/download-spm-artifacts.js +64 -6
  35. package/scripts/spm/expand-spm-dependencies.js +249 -30
  36. package/scripts/spm/generate-spm-autolinking.js +157 -40
  37. package/scripts/spm/generate-spm-package.js +30 -18
  38. package/scripts/spm/generate-spm-xcodeproj.js +595 -71
  39. package/scripts/spm/scaffold-package-swift.js +65 -24
  40. package/scripts/spm/spm-pbxproj.js +158 -28
  41. package/scripts/spm/spm-types.js +29 -3
  42. package/scripts/spm/spm-utils.js +117 -2
  43. package/sdks/.hermesv1version +1 -1
  44. package/sdks/hermes-engine/version.properties +1 -1
  45. package/types/public/ReactNativeTVTypes.d.ts +40 -1
  46. package/scripts/spm/__doc__/rfc-spm-xcframework.md +0 -707
  47. package/scripts/spm/__doc__/spm-autolinking-plugins.md +0 -244
  48. package/scripts/spm/__doc__/spm-header-paths-contract.md +0 -97
  49. package/scripts/spm/__doc__/spm-plugins-assessment.md +0 -128
  50. package/scripts/spm/__doc__/spm-scripts.md +0 -486
@@ -199,7 +199,7 @@ export class AnimatedEvent {
199
199
  this._attachedEvent && this._attachedEvent.detach();
200
200
  }
201
201
 
202
- __getHandler(): any | ((...args: any) => void) {
202
+ __getHandler(): (...args: any) => void {
203
203
  if (this.__isNative) {
204
204
  if (__DEV__) {
205
205
  let validatedMapping = false;
@@ -576,10 +576,14 @@ function unforkEventImpl(
576
576
  }
577
577
  }
578
578
 
579
- const eventImpl = function <T>(
579
+ // NOTE: With `useNativeDriver: true` this returns an `AnimatedEvent` instance
580
+ // rather than a callable handler. That object is only ever meant to be handed
581
+ // straight back to an animated component's event prop, so the declared type
582
+ // describes the handler shape both branches are consumed as.
583
+ const eventImpl: <T>(
580
584
  argMapping: ReadonlyArray<?Mapping>,
581
585
  config: EventConfig<T>,
582
- ): any {
586
+ ) => (...args: Array<any>) => void = function (argMapping, config): any {
583
587
  const animatedEvent = new AnimatedEvent(argMapping, config);
584
588
  if (animatedEvent.__isNative) {
585
589
  return animatedEvent;
@@ -13,7 +13,7 @@ import type {PlatformConfig} from '../AnimatedPlatformConfig';
13
13
  import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
14
14
  import invariant from 'invariant';
15
15
 
16
- type ValueListenerCallback = (state: {value: number, ...}) => unknown;
16
+ export type ValueListenerCallback = (state: {value: number}) => unknown;
17
17
 
18
18
  export type AnimatedNodeConfig = Readonly<{
19
19
  debugID?: string,
@@ -17,7 +17,7 @@ import type {
17
17
  InterpolationConfigType,
18
18
  } from './AnimatedInterpolation';
19
19
  import type AnimatedNode from './AnimatedNode';
20
- import type {AnimatedNodeConfig} from './AnimatedNode';
20
+ import type {AnimatedNodeConfig, ValueListenerCallback} from './AnimatedNode';
21
21
  import type AnimatedTracking from './AnimatedTracking';
22
22
 
23
23
  import NativeAnimatedHelper from '../../../src/private/animated/NativeAnimatedHelper';
@@ -138,7 +138,7 @@ export default class AnimatedValue extends AnimatedWithChildren {
138
138
  }
139
139
  }
140
140
 
141
- addListener(callback: (value: any) => unknown): string {
141
+ addListener(callback: ValueListenerCallback): string {
142
142
  const id = super.addListener(callback);
143
143
  this._listenerCount++;
144
144
  if (this.__isNative) {
@@ -55,6 +55,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig = {
55
55
  borderLeftColor: colorAttribute,
56
56
  snapToItemPadding: true,
57
57
  scrollAnimationEnabled: true,
58
+ scrollAnimationDuration: true,
59
+ scrollAnimationEasing: true,
58
60
  pointerEvents: true,
59
61
  scrollsChildToFocus: true,
60
62
  },
@@ -754,6 +754,32 @@ export interface ScrollViewProps
754
754
  */
755
755
  scrollAnimationEnabled?: boolean | undefined;
756
756
 
757
+ /**
758
+ * (Android TV only)
759
+ * Duration in milliseconds of the animated scroll that runs when focus moves to
760
+ * another item. Applies to the snap scroll (`snapToAlignment="item"` with
761
+ * per-item `scrollSnapAlign`/`scrollSnapOffset` markers) and to animated
762
+ * `scrollTo` commands; other focus scrolls are instant. When unset or <= 0, the platform default (~250ms) is used.
763
+ * On tvOS the focus engine owns this animation and the prop has no effect.
764
+ */
765
+ scrollAnimationDuration?: number | undefined;
766
+
767
+ /**
768
+ * (Android TV only)
769
+ * Easing curve of that animation, either a CSS easing keyword or explicit
770
+ * cubic-bezier control points `[x1, y1, x2, y2]`. When unset, the platform
771
+ * default curve (`AccelerateDecelerateInterpolator`) is used.
772
+ * On tvOS the focus engine owns this animation and the prop has no effect.
773
+ */
774
+ scrollAnimationEasing?:
775
+ | 'linear'
776
+ | 'ease'
777
+ | 'ease-in'
778
+ | 'ease-out'
779
+ | 'ease-in-out'
780
+ | [number, number, number, number]
781
+ | undefined;
782
+
757
783
  /**
758
784
  * When true, shows a horizontal scroll indicator.
759
785
  */
@@ -45,6 +45,7 @@ import Keyboard from '../Keyboard/Keyboard';
45
45
  import TextInputState from '../TextInput/TextInputState';
46
46
  import View from '../View/View';
47
47
  import processDecelerationRate from './processDecelerationRate';
48
+ import processScrollAnimationEasing from './processScrollAnimationEasing';
48
49
  import Commands from './ScrollViewCommands';
49
50
  import ScrollViewContext, {HORIZONTAL, VERTICAL} from './ScrollViewContext';
50
51
  import ScrollViewStickyHeader from './ScrollViewStickyHeader';
@@ -196,6 +197,13 @@ export interface ScrollViewImperativeMethods {
196
197
  }
197
198
 
198
199
  export type DecelerationRateType = 'fast' | 'normal' | number;
200
+ export type ScrollAnimationEasing =
201
+ | 'linear'
202
+ | 'ease'
203
+ | 'ease-in'
204
+ | 'ease-out'
205
+ | 'ease-in-out'
206
+ | [number, number, number, number];
199
207
  export type ScrollResponderType = ScrollViewImperativeMethods;
200
208
 
201
209
  export interface ScrollViewInstance
@@ -779,6 +787,23 @@ type ScrollViewBaseProps = Readonly<{
779
787
  * when focus changes. Defaults to true.
780
788
  */
781
789
  scrollAnimationEnabled?: ?boolean,
790
+ /**
791
+ * (Android TV only)
792
+ * Duration in milliseconds of the animated scroll that runs when focus moves to
793
+ * another item. Applies to the snap scroll (`snapToAlignment="item"` with
794
+ * per-item `scrollSnapAlign`/`scrollSnapOffset` markers) and to animated
795
+ * `scrollTo` commands; other focus scrolls are instant. When unset or <= 0, the platform default (~250ms) is used.
796
+ * On tvOS the focus engine owns this animation and the prop has no effect.
797
+ */
798
+ scrollAnimationDuration?: ?number,
799
+ /**
800
+ * (Android TV only)
801
+ * Easing curve of that animation, either a CSS easing keyword or explicit
802
+ * cubic-bezier control points `[x1, y1, x2, y2]`. When unset, the platform
803
+ * default curve (`AccelerateDecelerateInterpolator`) is used.
804
+ * On tvOS the focus engine owns this animation and the prop has no effect.
805
+ */
806
+ scrollAnimationEasing?: ?ScrollAnimationEasing,
782
807
  /**
783
808
  * A RefreshControl component, used to provide pull-to-refresh
784
809
  * functionality for the ScrollView. Only works for vertical ScrollViews
@@ -1938,6 +1963,13 @@ class ScrollView extends React.Component<ScrollViewProps, ScrollViewState> {
1938
1963
  props.decelerationRate = processDecelerationRate(decelerationRate);
1939
1964
  }
1940
1965
 
1966
+ const {scrollAnimationEasing} = this.props;
1967
+ if (scrollAnimationEasing != null) {
1968
+ props.scrollAnimationEasing = processScrollAnimationEasing(
1969
+ scrollAnimationEasing,
1970
+ );
1971
+ }
1972
+
1941
1973
  const refreshControl = this.props.refreshControl;
1942
1974
  const scrollViewRef = this._scrollView.getForwardingRef(
1943
1975
  this.props.scrollViewRef,
@@ -77,6 +77,8 @@ export const __INTERNAL_VIEW_CONFIG: PartialViewConfig =
77
77
  borderLeftColor: colorAttribute,
78
78
  snapToItemPadding: true,
79
79
  scrollAnimationEnabled: true,
80
+ scrollAnimationDuration: true,
81
+ scrollAnimationEasing: true,
80
82
  pointerEvents: true,
81
83
  isInvertedVirtualizedList: true,
82
84
  scrollsChildToFocus: true,
@@ -74,6 +74,17 @@ export type ScrollViewNativeProps = Readonly<{
74
74
  sendMomentumEvents?: ?boolean,
75
75
  showsHorizontalScrollIndicator?: ?boolean,
76
76
  scrollAnimationEnabled?: ?boolean,
77
+ scrollAnimationDuration?: ?number,
78
+ // Accepts the unresolved prop value; ScrollView passes it through
79
+ // processScrollAnimationEasing, so native only sees control points.
80
+ scrollAnimationEasing?: ?(
81
+ | 'linear'
82
+ | 'ease'
83
+ | 'ease-in'
84
+ | 'ease-out'
85
+ | 'ease-in-out'
86
+ | [number, number, number, number]
87
+ ),
77
88
  showsScrollIndex?: ?boolean,
78
89
  showsVerticalScrollIndicator?: ?boolean,
79
90
  snapToAlignment?: ?('start' | 'center' | 'end' | 'item'),
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow strict-local
8
+ * @format
9
+ */
10
+
11
+ import type {ScrollAnimationEasing} from './ScrollView';
12
+
13
+ type CubicBezierControlPoints = [number, number, number, number];
14
+
15
+ // The native side only understands cubic-bezier control points, so the CSS easing
16
+ // keywords are resolved to the control points those keywords are defined as.
17
+ const SCROLL_ANIMATION_EASING_KEYWORDS: Readonly<{
18
+ linear: CubicBezierControlPoints,
19
+ ease: CubicBezierControlPoints,
20
+ 'ease-in': CubicBezierControlPoints,
21
+ 'ease-out': CubicBezierControlPoints,
22
+ 'ease-in-out': CubicBezierControlPoints,
23
+ }> = {
24
+ linear: [0, 0, 1, 1],
25
+ ease: [0.25, 0.1, 0.25, 1],
26
+ 'ease-in': [0.42, 0, 1, 1],
27
+ 'ease-out': [0, 0, 0.58, 1],
28
+ 'ease-in-out': [0.42, 0, 0.58, 1],
29
+ };
30
+
31
+ function processScrollAnimationEasing(
32
+ easing: ScrollAnimationEasing,
33
+ ): CubicBezierControlPoints {
34
+ if (typeof easing === 'string') {
35
+ return SCROLL_ANIMATION_EASING_KEYWORDS[easing];
36
+ }
37
+ return easing;
38
+ }
39
+
40
+ export default processScrollAnimationEasing;
@@ -8,6 +8,7 @@
8
8
  * @format
9
9
  */
10
10
 
11
+ import type {BlurEvent, FocusEvent} from '../../Types/CoreEventTypes';
11
12
  import type {ViewProps} from '../View/ViewPropTypes';
12
13
  import type {ComponentOrHandleType} from './tagForComponentOrHandle';
13
14
 
@@ -16,6 +17,7 @@ import useMergeRefs from '../../Utilities/useMergeRefs';
16
17
  import View from '../View/View';
17
18
  import {Commands} from '../View/ViewNativeComponent';
18
19
  import tagForComponentOrHandle from './tagForComponentOrHandle';
20
+ import useFocusEnterLeave from './useFocusEnterLeave';
19
21
 
20
22
  const StyleSheet = require('../../StyleSheet/StyleSheet').default;
21
23
  const React = require('react');
@@ -51,6 +53,20 @@ type TVFocusGuideViewProps = $ReadOnly<{
51
53
  * When set to false, this view and all its subviews will be NOT focusable.
52
54
  */
53
55
  focusable?: boolean | void,
56
+
57
+ /**
58
+ * Fires once when focus enters this focus guide's subtree from outside.
59
+ * Unlike `onFocus`, which bubbles and fires for every descendant, this does
60
+ * not fire again when focus moves between descendants. Boundary-crossing
61
+ * only, same idea as `onMouseEnter` vs `onMouseOver`.
62
+ */
63
+ onFocusEnter?: ?(event: FocusEvent) => void,
64
+
65
+ /**
66
+ * Fires once when focus leaves this focus guide's subtree entirely. Does
67
+ * not fire when focus moves between descendants. Pairs with `onFocusEnter`.
68
+ */
69
+ onFocusLeave?: ?(event: BlurEvent) => void,
54
70
  }>;
55
71
 
56
72
  export type TVFocusGuideViewImperativeMethods = $ReadOnly<{
@@ -63,6 +79,10 @@ function TVFocusGuideView({
63
79
  destinations: destinationsProp,
64
80
  autoFocus,
65
81
  focusable,
82
+ onFocusEnter,
83
+ onFocusLeave,
84
+ onFocusCapture,
85
+ onBlurCapture,
66
86
  ref,
67
87
  ...props
68
88
  }: TVFocusGuideViewProps): React.Node {
@@ -103,6 +123,15 @@ function TVFocusGuideView({
103
123
 
104
124
  const mergedRef = useMergeRefs(setLocalRef, ref);
105
125
 
126
+ // Collapse the bubbled per-descendant focus/blur into a single enter when
127
+ // focus crosses into the guide's subtree and a single leave when it exits.
128
+ const {handleFocusCapture, handleBlurCapture} = useFocusEnterLeave(
129
+ onFocusEnter,
130
+ onFocusLeave,
131
+ onFocusCapture,
132
+ onBlurCapture,
133
+ );
134
+
106
135
  React.useEffect(() => {
107
136
  if (focusable === false) {
108
137
  setDestinations([]);
@@ -131,6 +160,8 @@ function TVFocusGuideView({
131
160
  isTVSelectable={tvOSSelectable}
132
161
  // Android TV only prop
133
162
  tvFocusable={focusable}
163
+ onFocusCapture={handleFocusCapture}
164
+ onBlurCapture={handleBlurCapture}
134
165
  />
135
166
  );
136
167
  }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow strict-local
8
+ * @format
9
+ */
10
+
11
+ import type {BlurEvent, FocusEvent} from '../../Types/CoreEventTypes';
12
+
13
+ import * as React from 'react';
14
+
15
+ type FocusEnterLeaveState = {
16
+ entered: boolean,
17
+ pendingLeave: ?TimeoutID,
18
+ };
19
+
20
+ export type FocusEnterLeaveHandlers = {
21
+ handleFocusCapture: ?(event: FocusEvent) => void,
22
+ handleBlurCapture: ?(event: BlurEvent) => void,
23
+ };
24
+
25
+ /**
26
+ * Collapses the per-descendant focus/blur events into a single enter (focus
27
+ * crosses into the subtree) and a single leave (focus fully exits). A
28
+ * child-to-child move fires blur then focus back to back, so the deferred
29
+ * leave gets cancelled by the incoming focus and nothing fires.
30
+ *
31
+ * Hooks the capture phase, not bubble: capture reaches the container before
32
+ * any descendant sees the event, so a descendant calling stopPropagation
33
+ * can't leave us desynced.
34
+ */
35
+ export default function useFocusEnterLeave(
36
+ onFocusEnter: ?(event: FocusEvent) => void,
37
+ onFocusLeave: ?(event: BlurEvent) => void,
38
+ onFocusCapture: ?(event: FocusEvent) => void,
39
+ onBlurCapture: ?(event: BlurEvent) => void,
40
+ ): FocusEnterLeaveHandlers {
41
+ const stateRef = React.useRef<FocusEnterLeaveState>({
42
+ entered: false,
43
+ pendingLeave: null,
44
+ });
45
+
46
+ const activeHandlers =
47
+ onFocusEnter !== undefined || onFocusLeave !== undefined;
48
+
49
+ const handleFocusCapture = React.useCallback(
50
+ (event: FocusEvent) => {
51
+ const state = stateRef.current;
52
+ if (state.pendingLeave != null) {
53
+ clearTimeout(state.pendingLeave);
54
+ state.pendingLeave = null;
55
+ }
56
+ if (!state.entered) {
57
+ state.entered = true;
58
+ onFocusEnter?.(event);
59
+ }
60
+ onFocusCapture?.(event);
61
+ },
62
+ [onFocusEnter, onFocusCapture],
63
+ );
64
+
65
+ const handleBlurCapture = React.useCallback(
66
+ (event: BlurEvent) => {
67
+ const state = stateRef.current;
68
+ if (state.pendingLeave != null) {
69
+ clearTimeout(state.pendingLeave);
70
+ }
71
+ // Defer: if focus lands on another descendant this same tick, the focus
72
+ // handler clears this before it runs.
73
+ state.pendingLeave = setTimeout(() => {
74
+ state.pendingLeave = null;
75
+ if (state.entered) {
76
+ state.entered = false;
77
+ onFocusLeave?.(event);
78
+ }
79
+ }, 0);
80
+ onBlurCapture?.(event);
81
+ },
82
+ [onFocusLeave, onBlurCapture],
83
+ );
84
+
85
+ // Reset on unmount so a remounted instance starts clean and no deferred
86
+ // leave fires after we're gone.
87
+ // If no active handlers, return without doing anything
88
+ React.useEffect(() => {
89
+ if (!activeHandlers) {
90
+ return;
91
+ }
92
+ const state = stateRef.current;
93
+ return () => {
94
+ if (state.pendingLeave != null) {
95
+ clearTimeout(state.pendingLeave);
96
+ state.pendingLeave = null;
97
+ }
98
+ state.entered = false;
99
+ };
100
+ }, [activeHandlers]);
101
+
102
+ // Nothing to track: hand back the caller's own capture handlers untouched.
103
+ // (Kept after the hooks above so the hook count stays stable per render.)
104
+ if (onFocusEnter == null && onFocusLeave == null) {
105
+ return {
106
+ handleFocusCapture: onFocusCapture,
107
+ handleBlurCapture: onBlurCapture,
108
+ };
109
+ }
110
+
111
+ return {handleFocusCapture, handleBlurCapture};
112
+ }
@@ -28,7 +28,7 @@
28
28
  export default class ReactNativeVersion {
29
29
  static major: number = 0;
30
30
  static minor: number = 87;
31
- static patch: number = 0;
31
+ static patch: number = 1;
32
32
  static prerelease: string | null = '0';
33
33
 
34
34
  static getVersionString(): string {
package/README.md CHANGED
@@ -255,6 +255,8 @@ class Game2048 extends React.Component {
255
255
  | autoFocus | boolean? | If true, `TVFocusGuide` will automatically manage focus for you. It will redirect the focus to the first focusable child on the first visit. It also remembers the last focused child and redirects the focus to it on the subsequent visits. `destinations` prop takes precedence over this prop when used together. |
256
256
  | focusable | boolean? | When set to false, this view and all its subviews will be NOT focusable. |
257
257
  | trapFocus* (Up, Down, Left, Right) | Prevents focus escaping from the container for the given directions. |
258
+ | onFocusEnter | function? | Fires once when focus enters the FocusGuideView's subtree from outside. Unlike `onFocus`, it does not fire again when focus moves between descendants. |
259
+ | onFocusLeave | function? | Fires once when focus leaves the FocusGuideView's subtree entirely. Does not fire when focus moves between descendants. |
258
260
 
259
261
  More information on the focus handling improvements above can be found in [this article](https://medium.com/xite-engineering/revolutionizing-focus-management-in-tv-applications-with-react-native-10ba69bd90).
260
262
 
@@ -316,6 +318,20 @@ class Game2048 extends React.Component {
316
318
  </ScrollView>
317
319
  ```
318
320
 
321
+ - _ScrollView animation timing (Android TV)_: The `scrollAnimationDuration` and `scrollAnimationEasing` props tune the animated scroll that runs when focus moves to another item. They apply to the snap scroll (`snapToAlignment="item"` with per-item `scrollSnapAlign`/`scrollSnapOffset` markers) and to animated `scrollTo` commands; other focus scrolls are instant. Both are Android TV only; on tvOS the focus engine owns this animation. When either prop is unset, the platform defaults are used, so behavior is unchanged.
322
+
323
+ | Prop (ScrollView) | Value | Description |
324
+ |---|---|---|
325
+ | scrollAnimationDuration | number? | Duration of the scroll animation in milliseconds. Unset or `<= 0` uses the platform default (~250ms). Android TV only. |
326
+ | scrollAnimationEasing | `'linear' \| 'ease' \| 'ease-in' \| 'ease-out' \| 'ease-in-out' \| [number, number, number, number]`? | Easing curve, as a CSS easing keyword or cubic-bezier control points `[x1, y1, x2, y2]`. Unset uses the platform default `AccelerateDecelerateInterpolator`. Android TV only. |
327
+
328
+ ```jsx
329
+ <ScrollView horizontal scrollAnimationDuration={500} scrollAnimationEasing="ease-out">
330
+ <Pressable style={{width: 300}}><Text>Item 1</Text></Pressable>
331
+ <Pressable style={{width: 300}}><Text>Item 2</Text></Pressable>
332
+ </ScrollView>
333
+ ```
334
+
319
335
  - _Interaction with existing ScrollView props_: The TV scroll props build on top of existing React Native ScrollView behavior. Here is how they interact:
320
336
 
321
337
  - `snapToAlignment="item"` does not require `snapToInterval` to be set. It works as a standalone snapping mode where each child's `scrollSnapAlign` or `scrollSnapOffset` determines the snap position. If `snapToInterval` is also set, the interval is applied as an additional constraint after the item offset is computed.
@@ -325,6 +341,7 @@ class Game2048 extends React.Component {
325
341
  - `scrollAnimationEnabled={false}` disables all scroll animations, including programmatic `scrollTo({animated: true})` calls. When disabled, all scrolling is instant.
326
342
  - `decelerationRate` has no effect when `scrollAnimationEnabled={false}`, since there is no animation to decelerate.
327
343
  - `scrollAnimationEnabled` and `snapToAlignment="item"` work well together. Snapping still occurs, but instantly instead of animated.
344
+ - `scrollAnimationDuration` and `scrollAnimationEasing` have no effect when `scrollAnimationEnabled={false}`, since there is no animation left to time.
328
345
 
329
346
  - _VirtualizedList_: We extend `VirtualizedList` to make virtualization work well with focus management in mind. All of the improvements that we made are automatically available to all the VirtualizedList based components such as `FlatList`.
330
347
  - Defaults: VirtualizeList contents are automatically wrapped with a `TVFocusGuideView` with `trapFocus*` properties enabled depending on the orientation of the list. This default makes sure that focus doesn't leave the list accidentally due to a virtualization issue etc. until reaching the beginning or the end of the list.
@@ -23,7 +23,7 @@ NSDictionary* RCTGetReactNativeVersion(void)
23
23
  __rnVersion = @{
24
24
  RCTVersionMajor: @(0),
25
25
  RCTVersionMinor: @(87),
26
- RCTVersionPatch: @(0),
26
+ RCTVersionPatch: @(1),
27
27
  RCTVersionPrerelease: @"0",
28
28
  };
29
29
  });
@@ -492,6 +492,34 @@ static BOOL RCTLayerTransformCollapsesAxis(CALayer *layer)
492
492
  return _props->isTVSelectable;
493
493
  }
494
494
 
495
+ // Directional focus destinations are looked up in the containing root view when
496
+ // `updateProps` runs. A destination set while this view is not attached yet --
497
+ // e.g. a nextFocus* value delivered together with the view's creation -- resolves
498
+ // to nil there, and `updateProps` never retries because the prop value does not
499
+ // change. Re-resolve pending destinations lazily: `enableDirectionalFocusGuides`
500
+ // only does work while the view is focused, at which point the hierarchy is
501
+ // guaranteed to be attached.
502
+ - (void)resolvePendingNextFocusTargets
503
+ {
504
+ UIView *rootView = [self containingRootView];
505
+
506
+ if (rootView == nil) {
507
+ return;
508
+ }
509
+ if (_nextFocusUp == nil && _props->nextFocusUp.has_value()) {
510
+ _nextFocusUp = [rootView viewWithTag:_props->nextFocusUp.value()];
511
+ }
512
+ if (_nextFocusDown == nil && _props->nextFocusDown.has_value()) {
513
+ _nextFocusDown = [rootView viewWithTag:_props->nextFocusDown.value()];
514
+ }
515
+ if (_nextFocusLeft == nil && _props->nextFocusLeft.has_value()) {
516
+ _nextFocusLeft = [rootView viewWithTag:_props->nextFocusLeft.value()];
517
+ }
518
+ if (_nextFocusRight == nil && _props->nextFocusRight.has_value()) {
519
+ _nextFocusRight = [rootView viewWithTag:_props->nextFocusRight.value()];
520
+ }
521
+ }
522
+
495
523
  // In tvOS, to support directional focus APIs, we add a UIFocusGuide for each
496
524
  // side of the view where a nextFocus has been set. Set layout constraints to
497
525
  // make the guide 1 px thick, and set the destination to the nextFocus object.
@@ -503,6 +531,8 @@ static BOOL RCTLayerTransformCollapsesAxis(CALayer *layer)
503
531
  if (!self.isFocused) {
504
532
  return;
505
533
  }
534
+
535
+ [self resolvePendingNextFocusTargets];
506
536
  if (self->_nextFocusUp != nil) {
507
537
  if (self.focusGuideUp == nil) {
508
538
  self.focusGuideUp = [UIFocusGuide new];
@@ -1,4 +1,4 @@
1
- VERSION_NAME=0.87.0-0
1
+ VERSION_NAME=0.87.1-0
2
2
  react.internal.publishingGroup=io.github.react-native-tvos
3
3
  react.internal.hermesPublishingGroup=com.facebook.hermes
4
4
 
@@ -87,11 +87,15 @@ public open class IntentModule(reactContext: ReactApplicationContext) :
87
87
  override fun onHostResume() {
88
88
  reactApplicationContext.removeLifecycleEventListener(this)
89
89
  synchronized(this@IntentModule) {
90
- for (pendingPromise in pendingOpenURLPromises) {
90
+ // getInitialURL can re-enter and re-add to pendingOpenURLPromises when the activity
91
+ // is still null at resume, so drain a snapshot (after clearing the list and listener)
92
+ // to avoid mutating the list being iterated (ConcurrentModificationException).
93
+ val pendingPromises = ArrayList(pendingOpenURLPromises)
94
+ pendingOpenURLPromises.clear()
95
+ initialURLListener = null
96
+ for (pendingPromise in pendingPromises) {
91
97
  getInitialURL(pendingPromise)
92
98
  }
93
- initialURLListener = null
94
- pendingOpenURLPromises.clear()
95
99
  }
96
100
  }
97
101
 
@@ -14,7 +14,7 @@ public object ReactNativeVersion {
14
14
  public val VERSION: Map<String, Any?> = mapOf(
15
15
  "major" to 0,
16
16
  "minor" to 87,
17
- "patch" to 0,
17
+ "patch" to 1,
18
18
  "prerelease" to "0"
19
19
  )
20
20
  }
@@ -8,6 +8,7 @@
8
8
  package com.facebook.react.views.scroll
9
9
 
10
10
  import android.animation.ObjectAnimator
11
+ import android.animation.TimeInterpolator
11
12
  import android.animation.ValueAnimator
12
13
  import android.content.Context
13
14
  import android.graphics.Canvas
@@ -197,6 +198,10 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
197
198
  private var scrollsChildToFocus = true
198
199
  internal var snapToItemPadding: Int = 0
199
200
  internal var scrollAnimationEnabled: Boolean = true
201
+ /** Values <= 0 keep the platform default duration. */
202
+ internal var scrollAnimationDuration: Int = 0
203
+ /** `null` keeps the platform default curve. */
204
+ internal var scrollAnimationInterpolator: TimeInterpolator? = null
200
205
  private var blockScrollDelta: Boolean = false
201
206
 
202
207
  // Interface property overrides
@@ -295,6 +300,8 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
295
300
  scrollsChildToFocus = true
296
301
  snapToItemPadding = 0
297
302
  scrollAnimationEnabled = true
303
+ scrollAnimationDuration = 0
304
+ scrollAnimationInterpolator = null
298
305
  blockScrollDelta = false
299
306
  }
300
307
 
@@ -767,7 +774,9 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
767
774
  val currentFocused = findFocus()
768
775
  val nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction)
769
776
  if (nextFocused != null && nextFocused !== currentFocused && nextFocused !== this) {
770
- nextFocused.requestFocus(direction)
777
+ if (nextFocused.requestFocus(direction)) {
778
+ ReactScrollViewHelper.playFocusNavigationSoundEffect(this, direction)
779
+ }
771
780
  handled = true
772
781
  }
773
782
  } else if (pagingEnabled) {
@@ -778,16 +787,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
778
787
  val nextFocused = FocusFinder.getInstance().findNextFocus(this, currentFocused, direction)
779
788
  val rootChild = getContentView()
780
789
  if (nextFocused != null && isDescendantOf(rootChild, nextFocused)) {
781
- if (snapToAlignment == SNAP_ALIGNMENT_ITEM) {
782
- // When snapToAlignment is "item", don't use smoothScrollToNextPage (which scrolls
783
- // by full page width and ignores snapToItemPadding). Just request focus —
784
- // requestChildFocus tryScrollSnapToChild handles scrolling with correct padding.
785
- nextFocused.requestFocus()
786
- } else {
787
- if (!isScrolledInView(nextFocused) && !isMostlyScrolledInView(nextFocused)) {
788
- smoothScrollToNextPage(direction)
789
- }
790
- nextFocused.requestFocus()
790
+ val focusMoved =
791
+ if (snapToAlignment == SNAP_ALIGNMENT_ITEM) {
792
+ // When snapToAlignment is "item", don't use smoothScrollToNextPage (which scrolls
793
+ // by full page width and ignores snapToItemPadding). Just request focus —
794
+ // requestChildFocus → tryScrollSnapToChild handles scrolling with correct padding.
795
+ nextFocused.requestFocus()
796
+ } else {
797
+ if (!isScrolledInView(nextFocused) && !isMostlyScrolledInView(nextFocused)) {
798
+ smoothScrollToNextPage(direction)
799
+ }
800
+ nextFocused.requestFocus()
801
+ }
802
+ if (focusMoved) {
803
+ ReactScrollViewHelper.playFocusNavigationSoundEffect(this, direction)
791
804
  }
792
805
  handled = true
793
806
  } else {
@@ -800,7 +813,12 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
800
813
 
801
814
  pagedArrowScrolling = false
802
815
  } else {
816
+ val focusedBeforeScroll = findFocus()
803
817
  handled = super.arrowScroll(direction)
818
+ // super.arrowScroll() can scroll without moving focus, so only click when focus moved.
819
+ if (handled && findFocus() !== focusedBeforeScroll) {
820
+ ReactScrollViewHelper.playFocusNavigationSoundEffect(this, direction)
821
+ }
804
822
  }
805
823
 
806
824
  return handled
@@ -1701,8 +1719,13 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
1701
1719
  // not animated at all.
1702
1720
  defaultFlingAnimator.cancel()
1703
1721
 
1704
- // Update the fling animator with new values
1705
- val duration = ReactScrollViewHelper.getDefaultScrollAnimationDuration(context)
1722
+ // Update the fling animator with new values. The animator is reused, so the interpolator must
1723
+ // be re-applied on every call to undo whatever a previous `scrollAnimationEasing` left on it.
1724
+ val duration =
1725
+ if (scrollAnimationDuration > 0) scrollAnimationDuration
1726
+ else ReactScrollViewHelper.getDefaultScrollAnimationDuration(context)
1727
+ defaultFlingAnimator.interpolator =
1728
+ scrollAnimationInterpolator ?: ReactScrollViewHelper.getDefaultScrollAnimationInterpolator()
1706
1729
  defaultFlingAnimator.setDuration(duration.toLong()).setIntValues(start, end)
1707
1730
 
1708
1731
  // Start the animator