From 8bb555ac19ea332d7b5b6a394e7dcef3a4132978 Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 8 Oct 2026 16:05:39 +0200 Subject: [PATCH] Redesign paused in debugger overlay (iOS) --- packages/react-native/React/Base/RCTBridge.mm | 1 - .../React/CoreModules/RCTDevBanner.h | 73 ++ .../React/CoreModules/RCTDevBanner.mm | 685 ++++++++++++++++++ .../React/CoreModules/RCTDevLoadingView.mm | 454 +----------- .../RCTPausedInDebuggerOverlayController.h | 0 .../RCTPausedInDebuggerOverlayController.mm | 144 ++++ .../RCTPausedInDebuggerOverlayController.mm | 136 ---- .../api-snapshots/ReactAppleDebugCxx.api | 19 + .../api-snapshots/ReactAppleFrameworksCxx.api | 19 + .../api-snapshots/ReactAppleNewarchCxx.api | 19 + .../api-snapshots/ReactAppleReleaseCxx.api | 19 + 11 files changed, 986 insertions(+), 583 deletions(-) create mode 100644 packages/react-native/React/CoreModules/RCTDevBanner.h create mode 100644 packages/react-native/React/CoreModules/RCTDevBanner.mm rename packages/react-native/React/{DevSupport => CoreModules}/RCTPausedInDebuggerOverlayController.h (100%) create mode 100644 packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.mm delete mode 100644 packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.mm diff --git a/packages/react-native/React/Base/RCTBridge.mm b/packages/react-native/React/Base/RCTBridge.mm index b2258a62c0b9..d3fc19acbc72 100644 --- a/packages/react-native/React/Base/RCTBridge.mm +++ b/packages/react-native/React/Base/RCTBridge.mm @@ -25,7 +25,6 @@ #import "RCTJSThread.h" #import "RCTLog.h" #import "RCTModuleData.h" -#import "RCTPausedInDebuggerOverlayController.h" #import "RCTPerformanceLogger.h" #import "RCTProfile.h" #import "RCTReloadCommand.h" diff --git a/packages/react-native/React/CoreModules/RCTDevBanner.h b/packages/react-native/React/CoreModules/RCTDevBanner.h new file mode 100644 index 000000000000..7722661f91b4 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTDevBanner.h @@ -0,0 +1,73 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * A floating capsule at the top of the screen, in a window of its own above the app, for messages shown during + * development. It centers where a system alert would, keeps clear of the safe area and of a foldable's hinge, and + * animates in and out. Banners stack: while several are visible, each sits below those with a lower stack level. + * The dev loading banner is one; each instance hosts one capsule at a time, and subclasses shape theirs through the + * hooks below. + */ +@interface RCTDevBanner : NSObject + +/** Whether the capsule is on screen or animating out. Main queue only. */ +@property (nonatomic, readonly, getter=isVisible) BOOL visible; + +/** Shows the message, replacing any capsule already on screen. Callable from any thread. */ +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton; + +/** Replaces the text of the capsule on screen, for updates that arrive quickly such as progress. Main queue only. */ +- (void)updateMessage:(NSString *)message; + +/** Animates the capsule out, after it has been on screen long enough to read. Callable from any thread. */ +- (void)hide; + +@end + +/** + * Hooks for subclasses, each with the loading banner's behavior by default. All run on the main queue. + */ +@interface RCTDevBanner (Subclassing) + +/** The level of the banner's window. */ +- (UIWindowLevel)windowLevel; + +/** Whether the window dims the app beneath the capsule and swallows touches on it. */ +- (BOOL)dimsBackground; + +/** Banners with a lower level stack above those with a higher one. */ +- (NSUInteger)stackLevel; + +/** The capsule's minimum height. */ +- (CGFloat)minimumHeight; + +/** The space between the capsule's edges and its label, or its leading accessory view where there is one. */ +- (NSDirectionalEdgeInsets)labelInsets; + +/** A view shown before the label, such as an icon, sized by its own constraints; nil for none. Called once per capsule. + */ +- (nullable UIView *)makeLeadingAccessoryView; + +/** The label's text for a message. */ +- (NSAttributedString *)attributedTextForMessage:(NSString *)message textColor:(UIColor *)textColor; + +/** The capsule's accessibility label, or nil to expose its subviews instead. */ +- (nullable NSString *)accessibilityLabelForMessage:(NSString *)message; + +/** What a tap on the capsule does. */ +- (void)didTapCapsule; + +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/react-native/React/CoreModules/RCTDevBanner.mm b/packages/react-native/React/CoreModules/RCTDevBanner.mm new file mode 100644 index 000000000000..af96ff5ff4ac --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTDevBanner.mm @@ -0,0 +1,685 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTDevBanner.h" + +#import + +#import +#import + +#if RCT_DEV_MENU + +static const CGFloat RCTDevBannerTopMargin = 16; +static const CGFloat RCTDevBannerHorizontalMargin = 16; +static const CGFloat RCTDevBannerLabelHorizontalPadding = 14; +static const CGFloat RCTDevBannerLabelVerticalPadding = 10; +static const CGFloat RCTDevBannerMinHeight = 40; +static const CGFloat RCTDevBannerDismissButtonSize = 22; +static const CGFloat RCTDevBannerFontSize = 12.5; +static const CGFloat RCTDevBannerLineSpacing = 2; +static const NSTimeInterval RCTDevBannerAnimationDuration = 0.2; +static const CGFloat RCTDevBannerHiddenScale = 0.85; +// The hidden state scales about a point this fraction of the capsule's height above its center. +static const CGFloat RCTDevBannerHiddenAnchorOffset = 0.05; +// A capsule stays on screen at least this long, so one shown and hidden in quick succession can still be read. +static const NSTimeInterval RCTDevBannerMinPresentedTime = 0.6; +static const CGFloat RCTDevBannerAccessorySpacing = 6; +static const CGFloat RCTDevBannerStackSpacing = 12; +static const CGFloat RCTDevBannerDimmingAlpha = 0.2; + +#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1 +#define RCT_DEV_BANNER_HAS_HINGE 1 +#else +#define RCT_DEV_BANNER_HAS_HINGE 0 +#endif + +// The horizontal margin a system alert keeps from each side of its window, unless the safe area inset there is larger. +static const CGFloat RCTDevBannerAlertMinimumMargin = 24; + +// The horizontal offset from the window's center to the line the banner centers on, matching where a system alert +// sits: each side inset, capped at the alert's minimum margin, pushes the alert away from it by half its size. The +// iPhone Duo's 84pt rail thus shifts it 12pt; equal insets, as on every other iPhone, leave it centered. No public API +// exposes this; it mirrors UIKit's alert presentation on iOS 27.1. +static CGFloat RCTDevBannerCenterLayoutAnchor(UIEdgeInsets safeAreaInsets) +{ + return (MIN(safeAreaInsets.left, RCTDevBannerAlertMinimumMargin) - + MIN(safeAreaInsets.right, RCTDevBannerAlertMinimumMargin)) / + 2; +} + +// The banners currently shown, held weakly, so one can stack below another. +static NSHashTable *RCTDevBannerVisibleBanners(void) +{ + static NSHashTable *banners; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + banners = [NSHashTable weakObjectsHashTable]; + }); + return banners; +} + +// Hosts the banner above the app's windows and passes touches outside it through to them. +@interface RCTDevBannerWindow : UIWindow +// Called after each layout pass, since the banner's placement depends on the window's size and safe area in +// ways Auto Layout can't express. +@property (nonatomic, copy) void (^layoutHandler)(void); +@end + +@implementation RCTDevBannerWindow + +- (void)layoutSubviews +{ + [super layoutSubviews]; + if (_layoutHandler != nil) { + _layoutHandler(); + } +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + UIView *view = [super hitTest:point withEvent:event]; + return view == self.rootViewController.view ? nil : view; +} + +@end + +@implementation RCTDevBanner { + RCTDevBannerWindow *_window; + UILabel *_label; + UIView *_container; + UIView *_contentView; + UIView *_dimmingView; + UIView *_accessoryView; + UIButton *_dismissButton; + NSLayoutConstraint *_labelTrailingConstraint; + NSLayoutConstraint *_labelToButtonConstraint; + NSLayoutConstraint *_centerConstraint; + NSLayoutConstraint *_trailingLimitConstraint; + NSLayoutConstraint *_topSafeAreaConstraint; + NSLayoutConstraint *_topMarginConstraint; + NSLayoutConstraint *_topPullConstraint; + NSDate *_showDate; + BOOL _hiding; + // Incremented by each show so a hide animation that it interrupts does not tear down the new banner. + NSUInteger _generation; +#if RCT_DEV_BANNER_HAS_HINGE + NSTimer *_divisionPollTimer; + NSDate *_divisionPollDeadline; + BOOL _hingeOpen; +#endif +} + +- (void)dealloc +{ +#if RCT_DEV_BANNER_HAS_HINGE + [_divisionPollTimer invalidate]; +#endif + UIWindow *window = _window; + _window = nil; + if (window) { + RCTExecuteOnMainQueue(^{ + window.hidden = YES; + }); + } +} + +- (BOOL)isVisible +{ + return _window != nil; +} + +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton +{ + dispatch_async(dispatch_get_main_queue(), ^{ + if (RCTRunningInTestEnvironment()) { + return; + } + + self->_showDate = [NSDate date]; + self->_generation++; + if (self->_hiding) { + [self _removeBanner]; + } + + if (self->_window == nullptr) { + UIWindowScene *windowScene = RCTKeyWindow().windowScene; + self->_window = windowScene != nil ? [[RCTDevBannerWindow alloc] initWithWindowScene:windowScene] + : [[RCTDevBannerWindow alloc] init]; + self->_window.frame = self->_window.windowScene.coordinateSpace.bounds; +#if !TARGET_OS_TV + self->_window.windowLevel = [self windowLevel]; +#endif + self->_window.rootViewController = [UIViewController new]; + __weak __typeof(self) weakSelf = self; + self->_window.layoutHandler = ^{ + [weakSelf _updatePlacementAnimated:NO]; + }; + self->_window.rootViewController.view.backgroundColor = UIColor.clearColor; +#if RCT_DEV_BANNER_HAS_HINGE + [self _observeHinge]; +#endif + } + + BOOL isNewBanner = self->_container == nullptr; + if (isNewBanner) { + [self _createBannerWithBackgroundColor:backgroundColor]; + } else { + [self _applyBackgroundColor:backgroundColor]; + } + + self->_label.textColor = color; + [self updateMessage:message]; + self->_container.accessibilityLabel = [self accessibilityLabelForMessage:message]; + self->_container.isAccessibilityElement = self->_container.accessibilityLabel != nil; + + if (dismissButton && self->_dismissButton == nullptr) { + [self _createDismissButton]; + } else if (!dismissButton && self->_dismissButton != nullptr) { + [self->_dismissButton removeFromSuperview]; + self->_dismissButton = nullptr; + self->_labelToButtonConstraint = nil; + } + if (self->_dismissButton != nullptr) { + UIButtonConfiguration *buttonConfig = self->_dismissButton.configuration; + buttonConfig.baseForegroundColor = color; + buttonConfig.background.backgroundColor = [color colorWithAlphaComponent:0.2]; + self->_dismissButton.configuration = buttonConfig; + } + self->_labelTrailingConstraint.active = self->_dismissButton == nullptr; + self->_labelToButtonConstraint.active = self->_dismissButton != nullptr; + + [RCTDevBannerVisibleBanners() addObject:self]; + self->_window.hidden = NO; + [self _updatePlacementAnimated:NO]; + [self->_window layoutIfNeeded]; + [self _updateOtherBannersAnimated:YES]; + if (isNewBanner) { + [self _animateBannerIn]; + } + }); +} + +- (void)updateMessage:(NSString *)message +{ + _label.attributedText = [self attributedTextForMessage:message textColor:_label.textColor]; +} + +- (void)hide +{ + dispatch_async(dispatch_get_main_queue(), ^{ + self->_hiding = YES; + NSUInteger generation = self->_generation; + NSTimeInterval presentedTime = [[NSDate date] timeIntervalSinceDate:self->_showDate]; + NSTimeInterval delay = MAX(0, RCTDevBannerMinPresentedTime - presentedTime); + [UIView animateWithDuration:RCTDevBannerAnimationDuration + delay:delay + options:UIViewAnimationOptionCurveEaseIn + animations:^{ + if ([self->_container isKindOfClass:UIVisualEffectView.class]) { + ((UIVisualEffectView *)self->_container).effect = nil; + self->_contentView.alpha = 0; + } else { + self->_container.alpha = 0; + } + self->_container.transform = [self _hiddenTransform]; + self->_dimmingView.alpha = 0; + } + completion:^(__unused BOOL finished) { + if (self->_generation != generation) { + return; + } + [self _removeBanner]; + [RCTDevBannerVisibleBanners() removeObject:self]; + [self _updateOtherBannersAnimated:YES]; + self->_window.hidden = YES; + self->_window = nil; + }]; + }); +} + +#pragma mark - Subclassing + +- (UIWindowLevel)windowLevel +{ + // Above the paused banner's dimming, so loading messages stay readable and tappable while the app is paused. + return UIWindowLevelStatusBar + 2; +} + +- (BOOL)dimsBackground +{ + return NO; +} + +- (NSUInteger)stackLevel +{ + return 1; +} + +- (CGFloat)minimumHeight +{ + return RCTDevBannerMinHeight; +} + +- (NSDirectionalEdgeInsets)labelInsets +{ + return NSDirectionalEdgeInsetsMake( + RCTDevBannerLabelVerticalPadding, + RCTDevBannerLabelHorizontalPadding, + RCTDevBannerLabelVerticalPadding, + RCTDevBannerLabelHorizontalPadding); +} + +- (UIView *)makeLeadingAccessoryView +{ + return nil; +} + +- (NSAttributedString *)attributedTextForMessage:(NSString *)message textColor:(__unused UIColor *)textColor +{ + NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; + paragraphStyle.alignment = NSTextAlignmentCenter; + paragraphStyle.lineSpacing = RCTDevBannerLineSpacing; + return [[NSAttributedString alloc] initWithString:message + attributes:@{NSParagraphStyleAttributeName : paragraphStyle}]; +} + +- (NSString *)accessibilityLabelForMessage:(__unused NSString *)message +{ + return nil; +} + +- (void)didTapCapsule +{ + [self hide]; +} + +#pragma mark - Placement + +#if RCT_DEV_BANNER_HAS_HINGE +- (void)_observeHinge +{ + if (@available(iOS 27.1, *)) { + __weak __typeof(self) weakSelf = self; + UIHingeInteraction *interaction = [[UIHingeInteraction alloc] + initWithUpdateHandler:^(__unused UIHingeInteraction *hingeInteraction, UIHingeInteractionUpdate *update) { + [weakSelf _hingeDidUpdate:update.hinge]; + }]; + [_window.rootViewController.view addInteraction:interaction]; + } +} + +// The division region becomes active once the hinge settles, matching when the system's own UI moves aside, +// but that change has no notification of its own, so each hinge update starts a short poll for it. +- (void)_hingeDidUpdate:(UIHinge *)hinge API_AVAILABLE(ios(27.1)) +{ + _hingeOpen = hinge.status == UIHingeStatusPartiallyOpen || hinge.status == UIHingeStatusFullyOpen; + _divisionPollDeadline = [NSDate dateWithTimeIntervalSinceNow:1.5]; + if (_divisionPollTimer != nil) { + return; + } + __weak __typeof(self) weakSelf = self; + _divisionPollTimer = + [NSTimer scheduledTimerWithTimeInterval:0.05 + repeats:YES + block:^(NSTimer *timer) { + __typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + [timer invalidate]; + return; + } + [strongSelf _updatePlacementAnimated:YES]; + if (strongSelf->_divisionPollDeadline.timeIntervalSinceNow < 0) { + [timer invalidate]; + strongSelf->_divisionPollTimer = nil; + } + }]; +} +#endif + +// Places the banner where a system alert would center itself, or while a fold divides the screen, centered within +// the leading segment and clear of the fold. On a foldable held open in portrait, the status bar sits in a corner +// while its inset spans the whole top edge, so the banner ignores that inset and sits 16pt from the top. A banner +// that stacks below others sits under them instead of at the top. +- (void)_updatePlacementAnimated:(BOOL)animated +{ + if (_container == nil) { + return; + } + UIView *rootView = _window.rootViewController.view; + UIEdgeInsets safeAreaInsets = rootView.safeAreaInsets; + CGFloat centerOffset = RCTDevBannerCenterLayoutAnchor(safeAreaInsets); + CGFloat trailingMargin = RCTDevBannerHorizontalMargin; + BOOL respectsTopSafeArea = YES; + CGFloat stackOffset = 0; + for (RCTDevBanner *banner in RCTDevBannerVisibleBanners()) { + if (banner != self && banner->_container != nil && [banner stackLevel] < [self stackLevel]) { + stackOffset += CGRectGetHeight(banner->_container.frame) + RCTDevBannerStackSpacing; + } + } +#if RCT_DEV_BANNER_HAS_HINGE + if (@available(iOS 27.1, *)) { + respectsTopSafeArea = !(_hingeOpen && CGRectGetHeight(rootView.bounds) > CGRectGetWidth(rootView.bounds)); + CGFloat minX = safeAreaInsets.left; + CGFloat maxX = CGRectGetWidth(rootView.bounds) - safeAreaInsets.right; + for (UIViewReservedRegion *region in + [rootView reservedRegionsOfKind:[UIViewReservedRegionKind divisionRegionKind]]) { + CGFloat dividerX = CGRectGetMinX(region.frame); + if (region.active && CGRectGetHeight(region.frame) >= CGRectGetWidth(region.frame) && dividerX > minX && + dividerX < maxX) { + centerOffset = (minX + dividerX) / 2 - CGRectGetMidX(rootView.bounds); + trailingMargin += maxX - dividerX; + } + } + } +#endif + if (centerOffset == _centerConstraint.constant && -trailingMargin == _trailingLimitConstraint.constant && + respectsTopSafeArea == _topSafeAreaConstraint.active && stackOffset == _topPullConstraint.constant) { + return; + } + _centerConstraint.constant = centerOffset; + _trailingLimitConstraint.constant = -trailingMargin; + _topSafeAreaConstraint.active = respectsTopSafeArea; + _topSafeAreaConstraint.constant = stackOffset; + _topMarginConstraint.constant = RCTDevBannerTopMargin + stackOffset; + _topPullConstraint.constant = stackOffset; + if (animated) { + [UIView animateWithDuration:RCTDevBannerAnimationDuration + animations:^{ + [rootView layoutIfNeeded]; + }]; + } +} + +- (void)_updateOtherBannersAnimated:(BOOL)animated +{ + for (RCTDevBanner *banner in [RCTDevBannerVisibleBanners() copy]) { + if (banner != self) { + [banner _updatePlacementAnimated:animated]; + } + } +} + +#pragma mark - Capsule + +- (CGAffineTransform)_hiddenTransform +{ + CGFloat anchorY = -RCTDevBannerHiddenAnchorOffset * _container.bounds.size.height; + CGAffineTransform transform = CGAffineTransformMakeTranslation(0, anchorY); + transform = CGAffineTransformScale(transform, RCTDevBannerHiddenScale, RCTDevBannerHiddenScale); + return CGAffineTransformTranslate(transform, 0, -anchorY); +} + +- (void)_animateBannerIn +{ + // A visual effect view renders incorrectly below full alpha, so its effect and content fade instead. + UIVisualEffectView *effectView = + [_container isKindOfClass:UIVisualEffectView.class] ? (UIVisualEffectView *)_container : nil; + UIVisualEffect *effect = effectView.effect; + effectView.effect = nil; + if (effectView != nil) { + _contentView.alpha = 0; + } else { + _container.alpha = 0; + } + _container.transform = [self _hiddenTransform]; + _dimmingView.alpha = 0; + + [UIView animateWithDuration:RCTDevBannerAnimationDuration + delay:0 + options:UIViewAnimationOptionCurveEaseOut | UIViewAnimationOptionAllowUserInteraction + animations:^{ + effectView.effect = effect; + self->_contentView.alpha = 1; + self->_container.alpha = 1; + self->_container.transform = CGAffineTransformIdentity; + self->_dimmingView.alpha = 1; + } + completion:nil]; +} + +- (void)_createBannerWithBackgroundColor:(UIColor *)backgroundColor +{ + UIView *rootView = _window.rootViewController.view; + if ([self dimsBackground]) { + [self _createDimmingView]; + } + + UIView *container; + UIView *contentView; +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + UIVisualEffectView *effectView = + [[UIVisualEffectView alloc] initWithEffect:[self _glassEffectWithTintColor:backgroundColor]]; + effectView.cornerConfiguration = [UICornerConfiguration capsuleConfiguration]; + container = effectView; + contentView = effectView.contentView; + } +#endif + if (container == nil) { + container = [UIView new]; + container.backgroundColor = backgroundColor; + container.layer.cornerRadius = [self minimumHeight] / 2; + container.layer.cornerCurve = kCACornerCurveContinuous; + container.clipsToBounds = YES; + contentView = container; + } + container.translatesAutoresizingMaskIntoConstraints = NO; + [container addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(didTapCapsule)]]; + + UILabel *label = [UILabel new]; + label.translatesAutoresizingMaskIntoConstraints = NO; + label.font = [UIFont monospacedDigitSystemFontOfSize:RCTDevBannerFontSize weight:UIFontWeightMedium]; + label.textAlignment = NSTextAlignmentCenter; + label.numberOfLines = 0; + [contentView addSubview:label]; + + [rootView addSubview:container]; + + // Centered on the window like a system alert, with required safe area limits that only push the banner + // inward where the two would overlap, such as beside the camera cutout. + UILayoutGuide *safeArea = rootView.safeAreaLayoutGuide; + NSDirectionalEdgeInsets labelInsets = [self labelInsets]; + _centerConstraint = [container.centerXAnchor constraintEqualToAnchor:rootView.centerXAnchor]; + _centerConstraint.priority = UILayoutPriorityDefaultHigh; + _trailingLimitConstraint = [container.trailingAnchor constraintLessThanOrEqualToAnchor:safeArea.trailingAnchor + constant:-RCTDevBannerHorizontalMargin]; + _labelTrailingConstraint = [label.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor + constant:-labelInsets.trailing]; + // Sits flush under a top safe area inset such as the status bar, or 16pt from the top edge without one, each + // pushed down by the banners stacked above it. + _topSafeAreaConstraint = [container.topAnchor constraintGreaterThanOrEqualToAnchor:safeArea.topAnchor]; + _topMarginConstraint = [container.topAnchor constraintGreaterThanOrEqualToAnchor:rootView.topAnchor + constant:RCTDevBannerTopMargin]; + _topPullConstraint = [container.topAnchor constraintEqualToAnchor:rootView.topAnchor]; + _topPullConstraint.priority = UILayoutPriorityDefaultLow; + [NSLayoutConstraint activateConstraints:@[ + _centerConstraint, + _trailingLimitConstraint, + [container.leadingAnchor constraintGreaterThanOrEqualToAnchor:safeArea.leadingAnchor + constant:RCTDevBannerHorizontalMargin], + _topSafeAreaConstraint, + _topMarginConstraint, + _topPullConstraint, + [container.heightAnchor constraintGreaterThanOrEqualToConstant:[self minimumHeight]], + [label.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:labelInsets.top], + [label.bottomAnchor constraintEqualToAnchor:contentView.bottomAnchor constant:-labelInsets.bottom], + ]]; + + UIView *accessoryView = [self makeLeadingAccessoryView]; + if (accessoryView != nil) { + accessoryView.translatesAutoresizingMaskIntoConstraints = NO; + [contentView addSubview:accessoryView]; + [NSLayoutConstraint activateConstraints:@[ + [accessoryView.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:labelInsets.leading], + [accessoryView.centerYAnchor constraintEqualToAnchor:contentView.centerYAnchor], + [label.leadingAnchor constraintEqualToAnchor:accessoryView.trailingAnchor constant:RCTDevBannerAccessorySpacing], + ]]; + } else { + [label.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor constant:labelInsets.leading].active = YES; + } + + _container = container; + _contentView = contentView; + _label = label; + _accessoryView = accessoryView; +} + +// Dims the app and swallows touches while a banner says it cannot respond to them. The dimming view sits below the +// capsule, which this window's hit testing leaves interactive. +- (void)_createDimmingView +{ + UIView *dimmingView = [UIView new]; + dimmingView.backgroundColor = [UIColor.blackColor colorWithAlphaComponent:RCTDevBannerDimmingAlpha]; + dimmingView.translatesAutoresizingMaskIntoConstraints = NO; + UIView *rootView = _window.rootViewController.view; + [rootView addSubview:dimmingView]; + [NSLayoutConstraint activateConstraints:@[ + [dimmingView.topAnchor constraintEqualToAnchor:rootView.topAnchor], + [dimmingView.bottomAnchor constraintEqualToAnchor:rootView.bottomAnchor], + [dimmingView.leadingAnchor constraintEqualToAnchor:rootView.leadingAnchor], + [dimmingView.trailingAnchor constraintEqualToAnchor:rootView.trailingAnchor], + ]]; + _dimmingView = dimmingView; +} + +- (void)_createDismissButton +{ + UIButtonConfiguration *buttonConfig = [UIButtonConfiguration plainButtonConfiguration]; + buttonConfig.image = [UIImage + systemImageNamed:@"xmark" + withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:10 weight:UIImageSymbolWeightBold]]; + buttonConfig.contentInsets = NSDirectionalEdgeInsetsZero; + buttonConfig.cornerStyle = UIButtonConfigurationCornerStyleCapsule; + + __weak __typeof(self) weakSelf = self; + UIButton *button = [UIButton buttonWithConfiguration:buttonConfig + primaryAction:[UIAction actionWithHandler:^(__unused UIAction *action) { + [weakSelf hide]; + }]]; + button.accessibilityLabel = @"Dismiss"; + button.translatesAutoresizingMaskIntoConstraints = NO; + [button setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + [_contentView addSubview:button]; + + _labelToButtonConstraint = [_label.trailingAnchor constraintEqualToAnchor:button.leadingAnchor constant:-8]; + [NSLayoutConstraint activateConstraints:@[ + [button.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-9], + [button.centerYAnchor constraintEqualToAnchor:_contentView.centerYAnchor], + [button.widthAnchor constraintEqualToConstant:RCTDevBannerDismissButtonSize], + [button.heightAnchor constraintEqualToConstant:RCTDevBannerDismissButtonSize], + ]]; + + _dismissButton = button; +} + +- (void)_removeBanner +{ + [_container removeFromSuperview]; + [_dimmingView removeFromSuperview]; + _container = nil; + _contentView = nil; + _dimmingView = nil; + _accessoryView = nil; + _label = nil; + _dismissButton = nil; + _labelTrailingConstraint = nil; + _labelToButtonConstraint = nil; + _centerConstraint = nil; + _trailingLimitConstraint = nil; + _topSafeAreaConstraint = nil; + _topMarginConstraint = nil; + _topPullConstraint = nil; + _hiding = NO; +} + +- (void)_applyBackgroundColor:(UIColor *)backgroundColor +{ +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 + if (@available(iOS 26.0, *)) { + if ([_container isKindOfClass:UIVisualEffectView.class]) { + ((UIVisualEffectView *)_container).effect = [self _glassEffectWithTintColor:backgroundColor]; + return; + } + } +#endif + _container.backgroundColor = backgroundColor; +} + +#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 +- (UIGlassEffect *)_glassEffectWithTintColor:(UIColor *)tintColor API_AVAILABLE(ios(26.0)) +{ + UIGlassEffect *effect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + effect.tintColor = tintColor; + return effect; +} +#endif + +@end + +#else + +@implementation RCTDevBanner + +- (BOOL)isVisible +{ + return NO; +} +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton +{ +} +- (void)updateMessage:(NSString *)message +{ +} +- (void)hide +{ +} +- (UIWindowLevel)windowLevel +{ + return UIWindowLevelNormal; +} +- (BOOL)dimsBackground +{ + return NO; +} +- (NSUInteger)stackLevel +{ + return 0; +} +- (CGFloat)minimumHeight +{ + return 0; +} +- (NSDirectionalEdgeInsets)labelInsets +{ + return NSDirectionalEdgeInsetsZero; +} +- (UIView *)makeLeadingAccessoryView +{ + return nil; +} +- (NSAttributedString *)attributedTextForMessage:(NSString *)message textColor:(UIColor *)textColor +{ + return [[NSAttributedString alloc] initWithString:message]; +} +- (NSString *)accessibilityLabelForMessage:(NSString *)message +{ + return nil; +} +- (void)didTapCapsule +{ +} + +@end + +#endif diff --git a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm index 749d7506615a..dc9223ca89f4 100644 --- a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm +++ b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm @@ -8,14 +8,13 @@ #import #include -#import - #import #import #import #import #import #import +#import #import #import @@ -28,85 +27,9 @@ @interface RCTDevLoadingView () #if RCT_DEV_MENU -static const CGFloat RCTDevLoadingViewTopMargin = 16; -static const CGFloat RCTDevLoadingViewHorizontalMargin = 16; -static const CGFloat RCTDevLoadingViewLabelHorizontalPadding = 14; -static const CGFloat RCTDevLoadingViewLabelVerticalPadding = 10; -static const CGFloat RCTDevLoadingViewMinHeight = 40; -static const CGFloat RCTDevLoadingViewDismissButtonSize = 22; -static const CGFloat RCTDevLoadingViewFontSize = 12.5; -static const CGFloat RCTDevLoadingViewLineSpacing = 2; -static const NSTimeInterval RCTDevLoadingViewAnimationDuration = 0.2; -static const CGFloat RCTDevLoadingViewHiddenScale = 0.85; -// The hidden state scales about a point this fraction of the capsule's height above its center. -static const CGFloat RCTDevLoadingViewHiddenAnchorOffset = 0.05; - -#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1 -#define RCT_DEV_LOADING_VIEW_HAS_HINGE 1 -#else -#define RCT_DEV_LOADING_VIEW_HAS_HINGE 0 -#endif - -// The horizontal margin a system alert keeps from each side of its window, unless the safe area inset there is larger. -static const CGFloat RCTDevLoadingViewAlertMinimumMargin = 24; - -// The horizontal offset from the window's center to the line the banner centers on, matching where a system alert -// sits: each side inset, capped at the alert's minimum margin, pushes the alert away from it by half its size. The -// iPhone Duo's 84pt rail thus shifts it 12pt; equal insets, as on every other iPhone, leave it centered. No public API -// exposes this; it mirrors UIKit's alert presentation on iOS 27.1. -static CGFloat RCTDevLoadingViewCenterLayoutAnchor(UIEdgeInsets safeAreaInsets) -{ - return (MIN(safeAreaInsets.left, RCTDevLoadingViewAlertMinimumMargin) - - MIN(safeAreaInsets.right, RCTDevLoadingViewAlertMinimumMargin)) / - 2; -} - -// Hosts the banner above the app's windows and passes touches outside it through to them. -@interface RCTDevLoadingWindow : UIWindow -// Called after each layout pass, since the banner's placement depends on the window's size and safe area in -// ways Auto Layout can't express. -@property (nonatomic, copy) void (^layoutHandler)(void); -@end - -@implementation RCTDevLoadingWindow - -- (void)layoutSubviews -{ - [super layoutSubviews]; - if (_layoutHandler != nil) { - _layoutHandler(); - } -} - -- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event -{ - UIView *view = [super hitTest:point withEvent:event]; - return view == self.rootViewController.view ? nil : view; -} - -@end - @implementation RCTDevLoadingView { - RCTDevLoadingWindow *_window; - UILabel *_label; - UIView *_container; - UIView *_contentView; - UIButton *_dismissButton; - NSLayoutConstraint *_labelTrailingConstraint; - NSLayoutConstraint *_labelToButtonConstraint; - NSLayoutConstraint *_centerConstraint; - NSLayoutConstraint *_trailingLimitConstraint; - NSLayoutConstraint *_topSafeAreaConstraint; - NSDate *_showDate; - BOOL _hiding; - // Incremented by each show so a hide animation that it interrupts does not tear down the new banner. - NSUInteger _generation; + RCTDevBanner *_banner; dispatch_block_t _initialMessageBlock; -#if RCT_DEV_LOADING_VIEW_HAS_HINGE - NSTimer *_divisionPollTimer; - NSDate *_divisionPollDeadline; - BOOL _hingeOpen; -#endif } RCT_EXPORT_MODULE() @@ -114,6 +37,7 @@ @implementation RCTDevLoadingView { - (instancetype)init { if (self = [super init]) { + _banner = [RCTDevBanner new]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(hide) name:RCTJavaScriptDidLoadNotification @@ -133,17 +57,7 @@ - (instancetype)init - (void)dealloc { [self clearInitialMessageDelay]; -#if RCT_DEV_LOADING_VIEW_HAS_HINGE - [_divisionPollTimer invalidate]; -#endif [[NSNotificationCenter defaultCenter] removeObserver:self]; - UIWindow *window = _window; - _window = nil; - if (window) { - RCTExecuteOnMainQueue(^{ - window.hidden = YES; - }); - } } + (void)setEnabled:(BOOL)enabled @@ -200,334 +114,8 @@ - (void)showMessage:(NSString *)message return; } - dispatch_async(dispatch_get_main_queue(), ^{ - if (RCTRunningInTestEnvironment()) { - return; - } - - self->_showDate = [NSDate date]; - self->_generation++; - if (self->_hiding) { - [self _removeBanner]; - } - - if (self->_window == nullptr) { - UIWindowScene *windowScene = RCTKeyWindow().windowScene; - self->_window = windowScene != nil ? [[RCTDevLoadingWindow alloc] initWithWindowScene:windowScene] - : [[RCTDevLoadingWindow alloc] init]; - self->_window.frame = self->_window.windowScene.coordinateSpace.bounds; -#if !TARGET_OS_TV - self->_window.windowLevel = UIWindowLevelStatusBar + 1; -#endif - self->_window.rootViewController = [UIViewController new]; - __weak __typeof(self) weakSelf = self; - self->_window.layoutHandler = ^{ - [weakSelf _updatePlacementAnimated:NO]; - }; - self->_window.rootViewController.view.backgroundColor = UIColor.clearColor; -#if RCT_DEV_LOADING_VIEW_HAS_HINGE - [self _observeHinge]; -#endif - } - - BOOL isNewBanner = self->_container == nullptr; - if (isNewBanner) { - [self _createBannerWithBackgroundColor:backgroundColor]; - } else { - [self _applyBackgroundColor:backgroundColor]; - } - - self->_label.textColor = color; - [self _setLabelText:message]; - - if (dismissButton && self->_dismissButton == nullptr) { - [self _createDismissButton]; - } else if (!dismissButton && self->_dismissButton != nullptr) { - [self->_dismissButton removeFromSuperview]; - self->_dismissButton = nullptr; - self->_labelToButtonConstraint = nil; - } - if (self->_dismissButton != nullptr) { - UIButtonConfiguration *buttonConfig = self->_dismissButton.configuration; - buttonConfig.baseForegroundColor = color; - buttonConfig.background.backgroundColor = [color colorWithAlphaComponent:0.2]; - self->_dismissButton.configuration = buttonConfig; - } - self->_labelTrailingConstraint.active = self->_dismissButton == nullptr; - self->_labelToButtonConstraint.active = self->_dismissButton != nullptr; - - self->_window.hidden = NO; - [self _updatePlacementAnimated:NO]; - [self->_window layoutIfNeeded]; - if (isNewBanner) { - [self _animateBannerIn]; - } - }); -} - -#if RCT_DEV_LOADING_VIEW_HAS_HINGE -- (void)_observeHinge -{ - if (@available(iOS 27.1, *)) { - __weak __typeof(self) weakSelf = self; - UIHingeInteraction *interaction = [[UIHingeInteraction alloc] - initWithUpdateHandler:^(__unused UIHingeInteraction *hingeInteraction, UIHingeInteractionUpdate *update) { - [weakSelf _hingeDidUpdate:update.hinge]; - }]; - [_window.rootViewController.view addInteraction:interaction]; - } -} - -// The division region becomes active once the hinge settles, matching when the system's own UI moves aside, -// but that change has no notification of its own, so each hinge update starts a short poll for it. -- (void)_hingeDidUpdate:(UIHinge *)hinge API_AVAILABLE(ios(27.1)) -{ - _hingeOpen = hinge.status == UIHingeStatusPartiallyOpen || hinge.status == UIHingeStatusFullyOpen; - _divisionPollDeadline = [NSDate dateWithTimeIntervalSinceNow:1.5]; - if (_divisionPollTimer != nil) { - return; - } - __weak __typeof(self) weakSelf = self; - _divisionPollTimer = - [NSTimer scheduledTimerWithTimeInterval:0.05 - repeats:YES - block:^(NSTimer *timer) { - __typeof(self) strongSelf = weakSelf; - if (strongSelf == nil) { - [timer invalidate]; - return; - } - [strongSelf _updatePlacementAnimated:YES]; - if (strongSelf->_divisionPollDeadline.timeIntervalSinceNow < 0) { - [timer invalidate]; - strongSelf->_divisionPollTimer = nil; - } - }]; + [_banner showMessage:message color:color backgroundColor:backgroundColor dismissButton:dismissButton]; } -#endif - -// Places the banner where a system alert would center itself, or while a fold divides the screen, centered within -// the leading segment and clear of the fold. On a foldable held open in portrait, the status bar sits in a corner -// while its inset spans the whole top edge, so the banner ignores that inset and sits 16pt from the top. -- (void)_updatePlacementAnimated:(BOOL)animated -{ - if (_container == nil) { - return; - } - UIView *rootView = _window.rootViewController.view; - UIEdgeInsets safeAreaInsets = rootView.safeAreaInsets; - CGFloat centerOffset = RCTDevLoadingViewCenterLayoutAnchor(safeAreaInsets); - CGFloat trailingMargin = RCTDevLoadingViewHorizontalMargin; - BOOL respectsTopSafeArea = YES; -#if RCT_DEV_LOADING_VIEW_HAS_HINGE - if (@available(iOS 27.1, *)) { - respectsTopSafeArea = !(_hingeOpen && CGRectGetHeight(rootView.bounds) > CGRectGetWidth(rootView.bounds)); - CGFloat minX = safeAreaInsets.left; - CGFloat maxX = CGRectGetWidth(rootView.bounds) - safeAreaInsets.right; - for (UIViewReservedRegion *region in - [rootView reservedRegionsOfKind:[UIViewReservedRegionKind divisionRegionKind]]) { - CGFloat dividerX = CGRectGetMinX(region.frame); - if (region.active && CGRectGetHeight(region.frame) >= CGRectGetWidth(region.frame) && dividerX > minX && - dividerX < maxX) { - centerOffset = (minX + dividerX) / 2 - CGRectGetMidX(rootView.bounds); - trailingMargin += maxX - dividerX; - } - } - } -#endif - if (centerOffset == _centerConstraint.constant && -trailingMargin == _trailingLimitConstraint.constant && - respectsTopSafeArea == _topSafeAreaConstraint.active) { - return; - } - _centerConstraint.constant = centerOffset; - _trailingLimitConstraint.constant = -trailingMargin; - _topSafeAreaConstraint.active = respectsTopSafeArea; - if (animated) { - [UIView animateWithDuration:RCTDevLoadingViewAnimationDuration - animations:^{ - [rootView layoutIfNeeded]; - }]; - } -} - -- (CGAffineTransform)_hiddenTransform -{ - CGFloat anchorY = -RCTDevLoadingViewHiddenAnchorOffset * _container.bounds.size.height; - CGAffineTransform transform = CGAffineTransformMakeTranslation(0, anchorY); - transform = CGAffineTransformScale(transform, RCTDevLoadingViewHiddenScale, RCTDevLoadingViewHiddenScale); - return CGAffineTransformTranslate(transform, 0, -anchorY); -} - -- (void)_animateBannerIn -{ - // A visual effect view renders incorrectly below full alpha, so its effect and content fade instead. - UIVisualEffectView *effectView = - [_container isKindOfClass:UIVisualEffectView.class] ? (UIVisualEffectView *)_container : nil; - UIVisualEffect *effect = effectView.effect; - effectView.effect = nil; - if (effectView != nil) { - _contentView.alpha = 0; - } else { - _container.alpha = 0; - } - _container.transform = [self _hiddenTransform]; - - [UIView animateWithDuration:RCTDevLoadingViewAnimationDuration - delay:0 - options:UIViewAnimationOptionCurveEaseOut | UIViewAnimationOptionAllowUserInteraction - animations:^{ - effectView.effect = effect; - self->_contentView.alpha = 1; - self->_container.alpha = 1; - self->_container.transform = CGAffineTransformIdentity; - } - completion:nil]; -} - -- (void)_createBannerWithBackgroundColor:(UIColor *)backgroundColor -{ - UIView *container; - UIView *contentView; -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, *)) { - UIVisualEffectView *effectView = - [[UIVisualEffectView alloc] initWithEffect:[self _glassEffectWithTintColor:backgroundColor]]; - effectView.cornerConfiguration = [UICornerConfiguration capsuleConfiguration]; - container = effectView; - contentView = effectView.contentView; - } -#endif - if (container == nil) { - container = [UIView new]; - container.backgroundColor = backgroundColor; - container.layer.cornerRadius = RCTDevLoadingViewMinHeight / 2; - container.layer.cornerCurve = kCACornerCurveContinuous; - container.clipsToBounds = YES; - contentView = container; - } - container.translatesAutoresizingMaskIntoConstraints = NO; - [container addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(hide)]]; - - UILabel *label = [UILabel new]; - label.translatesAutoresizingMaskIntoConstraints = NO; - label.font = [UIFont monospacedDigitSystemFontOfSize:RCTDevLoadingViewFontSize weight:UIFontWeightMedium]; - label.textAlignment = NSTextAlignmentCenter; - label.numberOfLines = 0; - [contentView addSubview:label]; - - UIView *rootView = _window.rootViewController.view; - [rootView addSubview:container]; - - // Centered on the window like a system alert, with required safe area limits that only push the banner - // inward where the two would overlap, such as beside the camera cutout. - UILayoutGuide *safeArea = rootView.safeAreaLayoutGuide; - _centerConstraint = [container.centerXAnchor constraintEqualToAnchor:rootView.centerXAnchor]; - _centerConstraint.priority = UILayoutPriorityDefaultHigh; - _trailingLimitConstraint = - [container.trailingAnchor constraintLessThanOrEqualToAnchor:safeArea.trailingAnchor - constant:-RCTDevLoadingViewHorizontalMargin]; - _labelTrailingConstraint = [label.trailingAnchor constraintEqualToAnchor:contentView.trailingAnchor - constant:-RCTDevLoadingViewLabelHorizontalPadding]; - // Sits flush under a top safe area inset such as the status bar, or 16pt from the top edge without one. - _topSafeAreaConstraint = [container.topAnchor constraintGreaterThanOrEqualToAnchor:safeArea.topAnchor]; - NSLayoutConstraint *topPull = [container.topAnchor constraintEqualToAnchor:rootView.topAnchor]; - topPull.priority = UILayoutPriorityDefaultLow; - [NSLayoutConstraint activateConstraints:@[ - _centerConstraint, - _trailingLimitConstraint, - [container.leadingAnchor constraintGreaterThanOrEqualToAnchor:safeArea.leadingAnchor - constant:RCTDevLoadingViewHorizontalMargin], - _topSafeAreaConstraint, - [container.topAnchor constraintGreaterThanOrEqualToAnchor:rootView.topAnchor constant:RCTDevLoadingViewTopMargin], - topPull, - [container.heightAnchor constraintGreaterThanOrEqualToConstant:RCTDevLoadingViewMinHeight], - [label.topAnchor constraintEqualToAnchor:contentView.topAnchor constant:RCTDevLoadingViewLabelVerticalPadding], - [label.bottomAnchor constraintEqualToAnchor:contentView.bottomAnchor - constant:-RCTDevLoadingViewLabelVerticalPadding], - [label.leadingAnchor constraintEqualToAnchor:contentView.leadingAnchor - constant:RCTDevLoadingViewLabelHorizontalPadding], - ]]; - - _container = container; - _contentView = contentView; - _label = label; -} - -- (void)_setLabelText:(NSString *)message -{ - NSMutableParagraphStyle *paragraphStyle = [NSMutableParagraphStyle new]; - paragraphStyle.alignment = NSTextAlignmentCenter; - paragraphStyle.lineSpacing = RCTDevLoadingViewLineSpacing; - _label.attributedText = [[NSAttributedString alloc] initWithString:message - attributes:@{NSParagraphStyleAttributeName : paragraphStyle}]; -} - -- (void)_createDismissButton -{ - UIButtonConfiguration *buttonConfig = [UIButtonConfiguration plainButtonConfiguration]; - buttonConfig.image = [UIImage - systemImageNamed:@"xmark" - withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:10 weight:UIImageSymbolWeightBold]]; - buttonConfig.contentInsets = NSDirectionalEdgeInsetsZero; - buttonConfig.cornerStyle = UIButtonConfigurationCornerStyleCapsule; - - __weak __typeof(self) weakSelf = self; - UIButton *button = [UIButton buttonWithConfiguration:buttonConfig - primaryAction:[UIAction actionWithHandler:^(__unused UIAction *action) { - [weakSelf hide]; - }]]; - button.accessibilityLabel = @"Dismiss"; - button.translatesAutoresizingMaskIntoConstraints = NO; - [button setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; - [_contentView addSubview:button]; - - _labelToButtonConstraint = [_label.trailingAnchor constraintEqualToAnchor:button.leadingAnchor constant:-8]; - [NSLayoutConstraint activateConstraints:@[ - [button.trailingAnchor constraintEqualToAnchor:_contentView.trailingAnchor constant:-9], - [button.centerYAnchor constraintEqualToAnchor:_contentView.centerYAnchor], - [button.widthAnchor constraintEqualToConstant:RCTDevLoadingViewDismissButtonSize], - [button.heightAnchor constraintEqualToConstant:RCTDevLoadingViewDismissButtonSize], - ]]; - - _dismissButton = button; -} - -- (void)_removeBanner -{ - [_container removeFromSuperview]; - _container = nil; - _contentView = nil; - _label = nil; - _dismissButton = nil; - _labelTrailingConstraint = nil; - _labelToButtonConstraint = nil; - _centerConstraint = nil; - _trailingLimitConstraint = nil; - _topSafeAreaConstraint = nil; - _hiding = NO; -} - -- (void)_applyBackgroundColor:(UIColor *)backgroundColor -{ -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, *)) { - if ([_container isKindOfClass:UIVisualEffectView.class]) { - ((UIVisualEffectView *)_container).effect = [self _glassEffectWithTintColor:backgroundColor]; - return; - } - } -#endif - _container.backgroundColor = backgroundColor; -} - -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 -- (UIGlassEffect *)_glassEffectWithTintColor:(UIColor *)tintColor API_AVAILABLE(ios(26.0)) -{ - UIGlassEffect *effect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; - effect.tintColor = tintColor; - return effect; -} -#endif - (void)showMessage:(NSString *)message withColor:(NSNumber *__nonnull)color @@ -548,42 +136,16 @@ - (void)hide // Cancel the initial message block so it doesn't display later and get stuck. [self clearInitialMessageDelay]; - dispatch_async(dispatch_get_main_queue(), ^{ - self->_hiding = YES; - NSUInteger generation = self->_generation; - const NSTimeInterval MIN_PRESENTED_TIME = 0.6; - NSTimeInterval presentedTime = [[NSDate date] timeIntervalSinceDate:self->_showDate]; - NSTimeInterval delay = MAX(0, MIN_PRESENTED_TIME - presentedTime); - [UIView animateWithDuration:RCTDevLoadingViewAnimationDuration - delay:delay - options:UIViewAnimationOptionCurveEaseIn - animations:^{ - if ([self->_container isKindOfClass:UIVisualEffectView.class]) { - ((UIVisualEffectView *)self->_container).effect = nil; - self->_contentView.alpha = 0; - } else { - self->_container.alpha = 0; - } - self->_container.transform = [self _hiddenTransform]; - } - completion:^(__unused BOOL finished) { - if (self->_generation != generation) { - return; - } - [self _removeBanner]; - self->_window.hidden = YES; - self->_window = nil; - }]; - }); + [_banner hide]; } - (void)showProgressMessage:(NSString *)message { - if (_window != nil) { + if (_banner.isVisible) { // This is an optimization. Since the progress can come in quickly, // we want to do the minimum amount of work to update the UI, // which is to only update the label text. - [self _setLabelText:message]; + [_banner updateMessage:message]; return; } @@ -630,7 +192,7 @@ - (void)showWithURL:(NSURL *)URL [self showOfflineMessage]; } else { [self showInitialMessageDelayed:^{ - NSString *message = [NSString stringWithFormat:@"Loading from %@\u2026", RCT_PACKAGER_NAME]; + NSString *message = [NSString stringWithFormat:@"Loading from %@…", RCT_PACKAGER_NAME]; [self showProgressMessage:message]; }]; } diff --git a/packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.h b/packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.h similarity index 100% rename from packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.h rename to packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.h diff --git a/packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.mm b/packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.mm new file mode 100644 index 000000000000..f1b1b7d32739 --- /dev/null +++ b/packages/react-native/React/CoreModules/RCTPausedInDebuggerOverlayController.mm @@ -0,0 +1,144 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +#import "RCTPausedInDebuggerOverlayController.h" + +#import + +// The debugger frontend sends a generic message with each pause; the banner shows React Native's own wording, which +// also tells someone who is not debugging what happened to the app. +static NSString *const RCTPausedInDebuggerMessage = @"App paused in debugger"; +static NSString *const RCTPausedInDebuggerSubtitle = @"Tap to resume"; + +static const CGFloat RCTPausedInDebuggerBannerMinHeight = 46; +static const CGFloat RCTPausedInDebuggerIconSize = 28; +static const CGFloat RCTPausedInDebuggerSubtitleFontSize = 10.5; + +/** + * The paused-in-debugger state of the dev banner: a yellow capsule with a leading pause icon and a "Tap to resume" + * line, over a dimmed app that takes no touches. It stays on top of any loading banner, and a tap on it resumes. + */ +@interface RCTPausedInDebuggerBanner : RCTDevBanner +@property (nonatomic, copy, nullable) dispatch_block_t onResume; +@end + +@implementation RCTPausedInDebuggerBanner + +- (void)show +{ + UIColor *backgroundColor = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *traits) { + return traits.userInterfaceStyle == UIUserInterfaceStyleDark + ? [UIColor colorWithRed:1 green:0.926 blue:0.595 alpha:1] + : [UIColor colorWithRed:1 green:0.941 blue:0.676 alpha:1]; + }]; + [self showMessage:RCTPausedInDebuggerMessage + color:UIColor.blackColor + backgroundColor:backgroundColor + dismissButton:NO]; +} + +// Below the loading banners' windows, so they stay readable and tappable above the dimming while the app is paused. +- (UIWindowLevel)windowLevel +{ + return UIWindowLevelStatusBar + 1; +} + +- (BOOL)dimsBackground +{ + return YES; +} + +- (NSUInteger)stackLevel +{ + return 0; +} + +- (CGFloat)minimumHeight +{ + return RCTPausedInDebuggerBannerMinHeight; +} + +- (NSDirectionalEdgeInsets)labelInsets +{ + CGFloat iconInset = (RCTPausedInDebuggerBannerMinHeight - RCTPausedInDebuggerIconSize) / 2; + return NSDirectionalEdgeInsetsMake(7, iconInset, 7, 16); +} + +- (UIView *)makeLeadingAccessoryView +{ + UIImageSymbolConfiguration *configuration = + [[UIImageSymbolConfiguration configurationWithPointSize:RCTPausedInDebuggerIconSize] + configurationByApplyingConfiguration:[UIImageSymbolConfiguration configurationWithPaletteColors:@[ + [UIColor colorWithRed:0.996 green:0.972 blue:0.869 alpha:1], + [UIColor colorWithRed:0.343 green:0.269 blue:0.105 alpha:1], + ]]]; + UIImageView *icon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"pause.circle.fill" + withConfiguration:configuration]]; + icon.contentMode = UIViewContentModeScaleAspectFit; + icon.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [icon.widthAnchor constraintEqualToConstant:RCTPausedInDebuggerIconSize], + [icon.heightAnchor constraintEqualToConstant:RCTPausedInDebuggerIconSize], + ]]; + return icon; +} + +- (NSAttributedString *)attributedTextForMessage:(NSString *)message textColor:(UIColor *)textColor +{ + NSMutableAttributedString *text = [[super attributedTextForMessage:message textColor:textColor] mutableCopy]; + NSMutableParagraphStyle *messageStyle = [[text attribute:NSParagraphStyleAttributeName atIndex:0 + effectiveRange:nil] mutableCopy]; + messageStyle.alignment = NSTextAlignmentNatural; + [text addAttribute:NSParagraphStyleAttributeName value:messageStyle range:NSMakeRange(0, text.length)]; + NSMutableParagraphStyle *subtitleStyle = [messageStyle mutableCopy]; + subtitleStyle.alignment = NSTextAlignmentCenter; + [text appendAttributedString:[[NSAttributedString alloc] + initWithString:[@"\n" stringByAppendingString:RCTPausedInDebuggerSubtitle] + attributes:@{ + NSParagraphStyleAttributeName : subtitleStyle, + NSFontAttributeName : + [UIFont systemFontOfSize:RCTPausedInDebuggerSubtitleFontSize + weight:UIFontWeightRegular], + NSForegroundColorAttributeName : [textColor colorWithAlphaComponent:0.6], + }]]; + return text; +} + +- (NSString *)accessibilityLabelForMessage:(NSString *)message +{ + return [NSString stringWithFormat:@"%@. %@.", message, RCTPausedInDebuggerSubtitle]; +} + +- (void)didTapCapsule +{ + if (_onResume != nil) { + _onResume(); + } +} + +@end + +@implementation RCTPausedInDebuggerOverlayController { + // A dedicated banner, so the paused state neither replaces nor is replaced by the loading messages. + RCTPausedInDebuggerBanner *_banner; +} + +- (void)showWithMessage:(__unused NSString *)message onResume:(void (^)(void))onResume +{ + if (_banner == nil) { + _banner = [RCTPausedInDebuggerBanner new]; + } + _banner.onResume = onResume; + [_banner show]; +} + +- (void)hide +{ + [_banner hide]; +} + +@end diff --git a/packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.mm b/packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.mm deleted file mode 100644 index 9237213bcf3e..000000000000 --- a/packages/react-native/React/DevSupport/RCTPausedInDebuggerOverlayController.mm +++ /dev/null @@ -1,136 +0,0 @@ -/* - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import -#include - -#import "RCTPausedInDebuggerOverlayController.h" - -@interface RCTPausedInDebuggerViewController : UIViewController -@property (nonatomic, copy) void (^onResume)(void); -@property (nonatomic, strong) NSString *message; -@end - -@interface RCTPausedInDebuggerOverlayController () - -@property (nonatomic, strong) UIWindow *alertWindow; - -@end - -@implementation RCTPausedInDebuggerViewController -- (void)viewDidLoad -{ - [super viewDidLoad]; - - UIView *dimmingView = [[UIView alloc] init]; - dimmingView.translatesAutoresizingMaskIntoConstraints = NO; - dimmingView.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.2]; - [self.view addSubview:dimmingView]; - [NSLayoutConstraint activateConstraints:@[ - [dimmingView.topAnchor constraintEqualToAnchor:self.view.topAnchor], - [dimmingView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], - [dimmingView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [dimmingView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor] - ]]; - - UILabel *messageLabel = [[UILabel alloc] init]; - messageLabel.text = self.message; - messageLabel.textAlignment = NSTextAlignmentCenter; - messageLabel.numberOfLines = 0; - messageLabel.font = [UIFont boldSystemFontOfSize:16]; - messageLabel.textColor = [UIColor blackColor]; - messageLabel.translatesAutoresizingMaskIntoConstraints = NO; - UIView *messageContainer = [[UIView alloc] init]; - [messageContainer addSubview:messageLabel]; - [NSLayoutConstraint activateConstraints:@[ - [messageLabel.topAnchor constraintEqualToAnchor:messageContainer.topAnchor constant:-1], - [messageLabel.bottomAnchor constraintEqualToAnchor:messageContainer.bottomAnchor], - [messageLabel.leadingAnchor constraintEqualToAnchor:messageContainer.leadingAnchor constant:16], - [messageLabel.trailingAnchor constraintEqualToAnchor:messageContainer.trailingAnchor], - ]]; - - UIButton *resumeButton = [UIButton buttonWithType:UIButtonTypeCustom]; - UIImage *image = [UIImage systemImageNamed:@"forward.frame.fill"]; - [resumeButton setImage:image forState:UIControlStateNormal]; - [resumeButton setImage:image forState:UIControlStateDisabled]; - resumeButton.tintColor = [UIColor colorWithRed:0.37 green:0.37 blue:0.37 alpha:1]; - - resumeButton.enabled = NO; - [NSLayoutConstraint activateConstraints:@[ - [resumeButton.widthAnchor constraintEqualToConstant:48], - [resumeButton.heightAnchor constraintEqualToConstant:46], - ]]; - - UIStackView *stackView = [[UIStackView alloc] initWithArrangedSubviews:@[ messageContainer, resumeButton ]]; - stackView.backgroundColor = [UIColor colorWithRed:1 green:1 blue:0.757 alpha:1]; - stackView.layer.cornerRadius = 12; - stackView.layer.borderWidth = 2; - stackView.layer.borderColor = [UIColor colorWithRed:0.71 green:0.71 blue:0.62 alpha:1].CGColor; - stackView.translatesAutoresizingMaskIntoConstraints = NO; - stackView.axis = UILayoutConstraintAxisHorizontal; - stackView.distribution = UIStackViewDistributionFill; - stackView.alignment = UIStackViewAlignmentCenter; - - UITapGestureRecognizer *gestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self - action:@selector(handleResume:)]; - [stackView addGestureRecognizer:gestureRecognizer]; - stackView.userInteractionEnabled = YES; - - [self.view addSubview:stackView]; - - [NSLayoutConstraint activateConstraints:@[ - [stackView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:12], - [stackView.centerXAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.centerXAnchor], - ]]; - - stackView.semanticContentAttribute = UISemanticContentAttributeForceLeftToRight; -} - -- (void)handleResume:(UITapGestureRecognizer *)recognizer -{ - self.onResume(); -} -@end - -@implementation RCTPausedInDebuggerOverlayController - -- (UIWindow *)alertWindow -{ - if (_alertWindow == nil) { - _alertWindow = [[UIWindow alloc] initWithWindowScene:RCTKeyWindow().windowScene]; - - if (_alertWindow != nullptr) { - _alertWindow.rootViewController = [UIViewController new]; - _alertWindow.windowLevel = UIWindowLevelAlert + 1; - } - } - - return _alertWindow; -} - -- (void)showWithMessage:(NSString *)message onResume:(void (^)(void))onResume -{ - [self hide]; - - RCTPausedInDebuggerViewController *view = [[RCTPausedInDebuggerViewController alloc] init]; - view.modalPresentationStyle = UIModalPresentationOverFullScreen; - view.message = message; - view.onResume = onResume; - [self.alertWindow makeKeyAndVisible]; - [self.alertWindow.rootViewController presentViewController:view animated:NO completion:nil]; -} - -- (void)hide -{ - [_alertWindow setHidden:YES]; - - _alertWindow.windowScene = nil; - - _alertWindow = nil; -} - -@end diff --git a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api index 4a7b602e202b..1a1f81f721c1 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleDebugCxx.api @@ -225,6 +225,18 @@ category RCTCxxConvert(NativeWebSocketModule_SpecConnectOptions) { public virtual static RCTManagedPointer* JS_NativeWebSocketModule_SpecConnectOptions:(id json); } +category RCTDevBanner(Subclassing) { + public virtual BOOL dimsBackground(); + public virtual CGFloat minimumHeight(); + public virtual NSAttributedString* attributedTextForMessage:textColor:(NSString* message, UIColor* textColor); + public virtual NSDirectionalEdgeInsets labelInsets(); + public virtual NSUInteger stackLevel(); + public virtual UIWindowLevel windowLevel(); + public virtual _Nullable NSString* accessibilityLabelForMessage:(NSString* message); + public virtual _Nullable UIView* makeLeadingAccessoryView(); + public virtual void didTapCapsule(); +} + category RCTScrollViewComponentView(ScrollableProtocol) { } @@ -943,6 +955,13 @@ interface RCTDecayAnimation : public NSObject { interface RCTDefaultReactNativeFactoryDelegate : public UIResponder { } +interface RCTDevBanner : public NSObject { + public @property (assign, readonly) BOOL visible; + public virtual void hide(); + public virtual void showMessage:color:backgroundColor:dismissButton:(NSString* message, UIColor* color, UIColor* backgroundColor, BOOL dismissButton); + public virtual void updateMessage:(NSString* message); +} + interface RCTDevLoadingView : public NSObject { } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api index cb120cc274d0..f96b4bbb3ea8 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleFrameworksCxx.api @@ -225,6 +225,18 @@ category RCTCxxConvert(NativeWebSocketModule_SpecConnectOptions) { public virtual static RCTManagedPointer* JS_NativeWebSocketModule_SpecConnectOptions:(id json); } +category RCTDevBanner(Subclassing) { + public virtual BOOL dimsBackground(); + public virtual CGFloat minimumHeight(); + public virtual NSAttributedString* attributedTextForMessage:textColor:(NSString* message, UIColor* textColor); + public virtual NSDirectionalEdgeInsets labelInsets(); + public virtual NSUInteger stackLevel(); + public virtual UIWindowLevel windowLevel(); + public virtual _Nullable NSString* accessibilityLabelForMessage:(NSString* message); + public virtual _Nullable UIView* makeLeadingAccessoryView(); + public virtual void didTapCapsule(); +} + category RCTScrollViewComponentView(ScrollableProtocol) { } @@ -952,6 +964,13 @@ interface RCTDecayAnimation : public NSObject { interface RCTDefaultReactNativeFactoryDelegate : public UIResponder { } +interface RCTDevBanner : public NSObject { + public @property (assign, readonly) BOOL visible; + public virtual void hide(); + public virtual void showMessage:color:backgroundColor:dismissButton:(NSString* message, UIColor* color, UIColor* backgroundColor, BOOL dismissButton); + public virtual void updateMessage:(NSString* message); +} + interface RCTDevLoadingView : public NSObject { } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api index 8a11f3569d73..4890c727ef49 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleNewarchCxx.api @@ -225,6 +225,18 @@ category RCTCxxConvert(NativeWebSocketModule_SpecConnectOptions) { public virtual static RCTManagedPointer* JS_NativeWebSocketModule_SpecConnectOptions:(id json); } +category RCTDevBanner(Subclassing) { + public virtual BOOL dimsBackground(); + public virtual CGFloat minimumHeight(); + public virtual NSAttributedString* attributedTextForMessage:textColor:(NSString* message, UIColor* textColor); + public virtual NSDirectionalEdgeInsets labelInsets(); + public virtual NSUInteger stackLevel(); + public virtual UIWindowLevel windowLevel(); + public virtual _Nullable NSString* accessibilityLabelForMessage:(NSString* message); + public virtual _Nullable UIView* makeLeadingAccessoryView(); + public virtual void didTapCapsule(); +} + category RCTScrollViewComponentView(ScrollableProtocol) { } @@ -943,6 +955,13 @@ interface RCTDecayAnimation : public NSObject { interface RCTDefaultReactNativeFactoryDelegate : public UIResponder { } +interface RCTDevBanner : public NSObject { + public @property (assign, readonly) BOOL visible; + public virtual void hide(); + public virtual void showMessage:color:backgroundColor:dismissButton:(NSString* message, UIColor* color, UIColor* backgroundColor, BOOL dismissButton); + public virtual void updateMessage:(NSString* message); +} + interface RCTDevLoadingView : public NSObject { } diff --git a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api index aa1ff6a9eb94..6a03f1ba63fd 100644 --- a/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api +++ b/scripts/cxx-api/api-snapshots/ReactAppleReleaseCxx.api @@ -225,6 +225,18 @@ category RCTCxxConvert(NativeWebSocketModule_SpecConnectOptions) { public virtual static RCTManagedPointer* JS_NativeWebSocketModule_SpecConnectOptions:(id json); } +category RCTDevBanner(Subclassing) { + public virtual BOOL dimsBackground(); + public virtual CGFloat minimumHeight(); + public virtual NSAttributedString* attributedTextForMessage:textColor:(NSString* message, UIColor* textColor); + public virtual NSDirectionalEdgeInsets labelInsets(); + public virtual NSUInteger stackLevel(); + public virtual UIWindowLevel windowLevel(); + public virtual _Nullable NSString* accessibilityLabelForMessage:(NSString* message); + public virtual _Nullable UIView* makeLeadingAccessoryView(); + public virtual void didTapCapsule(); +} + category RCTScrollViewComponentView(ScrollableProtocol) { } @@ -943,6 +955,13 @@ interface RCTDecayAnimation : public NSObject { interface RCTDefaultReactNativeFactoryDelegate : public UIResponder { } +interface RCTDevBanner : public NSObject { + public @property (assign, readonly) BOOL visible; + public virtual void hide(); + public virtual void showMessage:color:backgroundColor:dismissButton:(NSString* message, UIColor* color, UIColor* backgroundColor, BOOL dismissButton); + public virtual void updateMessage:(NSString* message); +} + interface RCTDevLoadingView : public NSObject { }