diff --git a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.h b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.h index 82f2adbeb7a5..92e02536382d 100644 --- a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.h +++ b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.h @@ -39,6 +39,11 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, assign) NSTimeInterval loadingViewFadeDuration; @property (nonatomic, assign) CGSize minimumSize; +#if TARGET_OS_TV +@property (nonatomic, copy, nullable) NSArray> *reactPreferredFocusEnvironments; +@property (nonatomic, weak, nullable) UIView *reactPreferredFocusedView; +#endif + - (instancetype)init NS_UNAVAILABLE; + (instancetype)new NS_UNAVAILABLE; - (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; diff --git a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.mm b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.mm index e157d2fa2ac4..1082e2243580 100644 --- a/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.mm +++ b/packages/react-native/React/Base/Surface/SurfaceHostingView/RCTSurfaceHostingProxyRootView.mm @@ -121,6 +121,13 @@ - (void)surface:(RCTSurface *)surface didChangeStage:(RCTSurfaceStage)stage [super surface:surface didChangeStage:stage]; if (RCTSurfaceStageIsRunning(stage)) { [_bridge.performanceLogger markStopForTag:RCTPLTTI]; +#if TARGET_OS_TV + dispatch_async(dispatch_get_main_queue(), ^{ + self.reactPreferredFocusedView = nil; + [self setNeedsFocusUpdate]; + [self updateFocusIfNeeded]; + }); +#endif } } @@ -154,4 +161,23 @@ - (void)cancelTouches // Not supported. } +#if TARGET_OS_TV +#pragma mark - UIFocusEnvironment + +- (NSArray> *)preferredFocusEnvironments +{ + if (self.reactPreferredFocusEnvironments != nil && self.reactPreferredFocusedView.window != nil) { + NSArray> *tempReactPreferredFocusEnvironments = self.reactPreferredFocusEnvironments; + self.reactPreferredFocusEnvironments = nil; + return tempReactPreferredFocusEnvironments; + } + + if (self.reactPreferredFocusedView && self.reactPreferredFocusedView.window != nil) { + return @[ self.reactPreferredFocusedView ]; + } + + return [super preferredFocusEnvironments]; +} +#endif + @end diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 71dd62f9b10b..bee9270010e3 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -6,6 +6,7 @@ */ #import "RCTViewComponentView.h" +#import #import #import @@ -53,6 +54,7 @@ @implementation RCTViewComponentView { BOOL _useCustomContainerView; NSMutableSet *_accessibilityOrderNativeIDs; RCTSwiftUIContainerViewWrapper *_swiftUIWrapper; + BOOL _focusable; } #ifdef RCT_DYNAMIC_FRAMEWORKS @@ -481,6 +483,13 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & needsInvalidateLayer = YES; } + // `focusable` +#if TARGET_OS_TV + if (oldViewProps.focusable != newViewProps.focusable) { + _focusable = (bool)newViewProps.focusable; + } +#endif + // `mixBlendMode` if (oldViewProps.mixBlendMode != newViewProps.mixBlendMode) { switch (newViewProps.mixBlendMode) { @@ -1422,6 +1431,11 @@ - (BOOL)wantsToCooptLabel return !super.accessibilityLabel && super.isAccessibilityElement; } +- (BOOL)canBecomeFocused +{ + return _focusable; +} + - (BOOL)isAccessibilityElement { if (self.contentView != nil) { @@ -1671,6 +1685,8 @@ - (void)transferVisualPropertiesFromView:(UIView *)sourceView toView:(UIView *)d } } +#pragma mark - Focus Events + - (BOOL)canBecomeFirstResponder { return YES; @@ -1689,9 +1705,36 @@ - (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args } } +#if TARGET_OS_TV +/// Finds the containing RCTSurfaceHostingProxyRootView by walking up the view +/// hierarchy. +- (RCTSurfaceHostingProxyRootView *)containingRootView +{ + UIView *view = self; + while (view != nil) { + if ([view isKindOfClass:[RCTSurfaceHostingProxyRootView class]]) { + return (RCTSurfaceHostingProxyRootView *)view; + } + view = view.superview; + } + return nil; +} +#endif + - (void)focus { [self becomeFirstResponder]; + +#if TARGET_OS_TV + RCTSurfaceHostingProxyRootView *rootView = [self containingRootView]; + if (rootView == nil) { + return; + } + + rootView.reactPreferredFocusedView = self; + [rootView setNeedsFocusUpdate]; + [rootView updateFocusIfNeeded]; +#endif } - (void)blur @@ -1699,8 +1742,6 @@ - (void)blur [self resignFirstResponder]; } -#pragma mark - Focus Events - - (BOOL)becomeFirstResponder { if (![super becomeFirstResponder]) { @@ -1727,6 +1768,30 @@ - (BOOL)resignFirstResponder return YES; } +#if TARGET_OS_TV + +- (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context + withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator +{ + if (context.previouslyFocusedView == context.nextFocusedView) { + return; + } + + if (context.nextFocusedView == self) { + if (_eventEmitter) { + _eventEmitter->onFocus(); + } + } else { + if (_eventEmitter) { + _eventEmitter->onBlur(); + } + } + + [super didUpdateFocusInContext:context withAnimationCoordinator:coordinator]; +} + +#endif + @end #ifdef __cplusplus