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.
- package/Libraries/Animated/AnimatedEvent.js +1 -1
- package/Libraries/Animated/AnimatedImplementation.js +6 -2
- package/Libraries/Animated/nodes/AnimatedNode.js +1 -1
- package/Libraries/Animated/nodes/AnimatedValue.js +2 -2
- package/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent.js +2 -0
- package/Libraries/Components/ScrollView/ScrollView.d.ts +26 -0
- package/Libraries/Components/ScrollView/ScrollView.js +32 -0
- package/Libraries/Components/ScrollView/ScrollViewNativeComponent.js +2 -0
- package/Libraries/Components/ScrollView/ScrollViewNativeComponentType.js +11 -0
- package/Libraries/Components/ScrollView/processScrollAnimationEasing.js +40 -0
- package/Libraries/Components/TV/TVFocusGuideView.js +31 -0
- package/Libraries/Components/TV/useFocusEnterLeave.js +112 -0
- package/Libraries/Core/ReactNativeVersion.js +1 -1
- package/README.md +17 -0
- package/React/Base/RCTVersion.m +1 -1
- package/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +30 -0
- package/ReactAndroid/gradle.properties +1 -1
- package/ReactAndroid/src/main/java/com/facebook/react/modules/intent/IntentModule.kt +7 -3
- package/ReactAndroid/src/main/java/com/facebook/react/modules/systeminfo/ReactNativeVersion.kt +1 -1
- package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt +36 -13
- package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollViewManager.kt +13 -0
- package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.kt +25 -3
- package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewHelper.kt +62 -0
- package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewManager.kt +13 -0
- package/ReactCommon/cxxreact/ReactNativeVersion.h +2 -2
- package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.cpp +26 -0
- package/ReactCommon/react/renderer/components/scrollview/platform/android/react/renderer/components/scrollview/HostPlatformScrollViewProps.h +3 -0
- package/ReactCommon/react/renderer/textlayoutmanager/platform/ios/react/renderer/textlayoutmanager/RCTFontUtils.mm +1 -1
- package/package.json +8 -8
- package/scripts/cocoapods/rndependencies.rb +31 -25
- package/scripts/replace-rncore-version.js +18 -2
- package/scripts/setup-apple-spm.js +28 -12
- package/scripts/spm/autolinking-plugins.js +80 -0
- package/scripts/spm/download-spm-artifacts.js +64 -6
- package/scripts/spm/expand-spm-dependencies.js +249 -30
- package/scripts/spm/generate-spm-autolinking.js +157 -40
- package/scripts/spm/generate-spm-package.js +30 -18
- package/scripts/spm/generate-spm-xcodeproj.js +595 -71
- package/scripts/spm/scaffold-package-swift.js +65 -24
- package/scripts/spm/spm-pbxproj.js +158 -28
- package/scripts/spm/spm-types.js +29 -3
- package/scripts/spm/spm-utils.js +117 -2
- package/sdks/.hermesv1version +1 -1
- package/sdks/hermes-engine/version.properties +1 -1
- package/types/public/ReactNativeTVTypes.d.ts +40 -1
- package/scripts/spm/__doc__/rfc-spm-xcframework.md +0 -707
- package/scripts/spm/__doc__/spm-autolinking-plugins.md +0 -244
- package/scripts/spm/__doc__/spm-header-paths-contract.md +0 -97
- package/scripts/spm/__doc__/spm-plugins-assessment.md +0 -128
- 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():
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
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.
|
package/React/Base/RCTVersion.m
CHANGED
|
@@ -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];
|
|
@@ -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
|
-
|
|
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
|
|
package/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt
CHANGED
|
@@ -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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
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
|