diff --git a/packages/react-native/Libraries/Utilities/DevLoadingView.js b/packages/react-native/Libraries/Utilities/DevLoadingView.js index 338a5614df35bc..d17e25146e145a 100644 --- a/packages/react-native/Libraries/Utilities/DevLoadingView.js +++ b/packages/react-native/Libraries/Utilities/DevLoadingView.js @@ -44,7 +44,11 @@ const COLOR_SCHEME = { }; export default { - showMessage(message: string, type: 'load' | 'refresh' | 'error') { + showMessage( + message: string, + type: 'load' | 'refresh' | 'error', + options?: {dismissButton?: boolean}, + ) { if (NativeDevLoadingView) { const colorScheme = getColorScheme() === 'dark' ? COLOR_SCHEME.dark : COLOR_SCHEME.default; @@ -59,10 +63,13 @@ export default { textColor = processColor(colorSet.textColor); } + const hasDismissButton = options?.dismissButton ?? false; + NativeDevLoadingView.showMessage( message, typeof textColor === 'number' ? textColor : null, typeof backgroundColor === 'number' ? backgroundColor : null, + hasDismissButton, ); } }, diff --git a/packages/react-native/Libraries/Utilities/HMRClient.js b/packages/react-native/Libraries/Utilities/HMRClient.js index 685e79707c1743..40245e5a9fe4bd 100644 --- a/packages/react-native/Libraries/Utilities/HMRClient.js +++ b/packages/react-native/Libraries/Utilities/HMRClient.js @@ -307,6 +307,7 @@ function setHMRUnavailableReason(reason: string) { DevLoadingView.showMessage( 'Fast Refresh disconnected. Reload app to reconnect.', 'error', + {dismissButton: true}, ); console.warn(reason); // (Not using the `warning` module to prevent a Buck cycle.) diff --git a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm index 0dd2a7f100cb25..846a6684dee05f 100644 --- a/packages/react-native/React/CoreModules/RCTDevLoadingView.mm +++ b/packages/react-native/React/CoreModules/RCTDevLoadingView.mm @@ -32,6 +32,7 @@ @implementation RCTDevLoadingView { UIWindow *_window; UILabel *_label; UIView *_container; + UIButton *_dismissButton; NSDate *_showDate; BOOL _hiding; dispatch_block_t _initialMessageBlock; @@ -85,7 +86,10 @@ - (void)showInitialMessageDelayed:(void (^)())initialMessage dispatch_time(DISPATCH_TIME_NOW, 0.2 * NSEC_PER_SEC), dispatch_get_main_queue(), self->_initialMessageBlock); } -- (void)showMessage:(NSString *)message color:(UIColor *)color backgroundColor:(UIColor *)backgroundColor +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton { if (!RCTDevLoadingViewGetEnabled() || _hiding) { return; @@ -120,49 +124,93 @@ - (void)showMessage:(NSString *)message color:(UIColor *)color backgroundColor:( self->_container = [[UIView alloc] init]; self->_container.backgroundColor = backgroundColor; self->_container.translatesAutoresizingMaskIntoConstraints = NO; + self->_container.clipsToBounds = YES; UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(hide)]; [self->_container addGestureRecognizer:tapGesture]; self->_container.userInteractionEnabled = YES; + if (dismissButton) { + CGFloat hue = 0.0; + CGFloat saturation = 0.0; + CGFloat brightness = 0.0; + CGFloat alpha = 0.0; + [backgroundColor getHue:&hue saturation:&saturation brightness:&brightness alpha:&alpha]; + UIColor *darkerBackground = [UIColor colorWithHue:hue + saturation:saturation + brightness:brightness * 0.7 + alpha:1.0]; + + UIButtonConfiguration *buttonConfig = [UIButtonConfiguration plainButtonConfiguration]; + buttonConfig.attributedTitle = [[NSAttributedString alloc] + initWithString:@"Dismiss ✕" + attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:11.0 weight:UIFontWeightRegular]}]; + buttonConfig.contentInsets = NSDirectionalEdgeInsetsMake(6, 12, 6, 12); + buttonConfig.background.backgroundColor = darkerBackground; + buttonConfig.background.cornerRadius = 10; + buttonConfig.baseForegroundColor = color; + + self->_dismissButton = [UIButton buttonWithConfiguration:buttonConfig primaryAction:nil]; + self->_dismissButton.translatesAutoresizingMaskIntoConstraints = NO; + [self->_dismissButton addTarget:self action:@selector(hide) forControlEvents:UIControlEventTouchUpInside]; + } + self->_label = [[UILabel alloc] init]; self->_label.translatesAutoresizingMaskIntoConstraints = NO; self->_label.font = [UIFont monospacedDigitSystemFontOfSize:12.0 weight:UIFontWeightRegular]; self->_label.textAlignment = NSTextAlignmentCenter; self->_label.textColor = color; self->_label.text = message; + self->_label.numberOfLines = 0; [self->_window.rootViewController.view addSubview:self->_container]; + if (dismissButton) { + [self->_container addSubview:self->_dismissButton]; + } [self->_container addSubview:self->_label]; CGFloat topSafeAreaHeight = mainWindow.safeAreaInsets.top; - CGFloat height = topSafeAreaHeight + 25; - self->_window.frame = CGRectMake(0, 0, mainWindow.frame.size.width, height); - self->_window.hidden = NO; [self->_window layoutIfNeeded]; - [NSLayoutConstraint activateConstraints:@[ + NSMutableArray *constraints = [NSMutableArray arrayWithArray:@[ // Container constraints [self->_container.topAnchor constraintEqualToAnchor:self->_window.rootViewController.view.topAnchor], [self->_container.leadingAnchor constraintEqualToAnchor:self->_window.rootViewController.view.leadingAnchor], [self->_container.trailingAnchor constraintEqualToAnchor:self->_window.rootViewController.view.trailingAnchor], - [self->_container.heightAnchor constraintEqualToConstant:height], // Label constraints - [self->_label.centerXAnchor constraintEqualToAnchor:self->_container.centerXAnchor], - [self->_label.bottomAnchor constraintEqualToAnchor:self->_container.bottomAnchor constant:-5], + [self->_label.topAnchor constraintEqualToAnchor:self->_container.topAnchor constant:topSafeAreaHeight + 8], + [self->_label.leadingAnchor constraintEqualToAnchor:self->_container.leadingAnchor constant:10], + [self->_label.bottomAnchor constraintEqualToAnchor:self->_container.bottomAnchor constant:-8], ]]; + + // Add button-specific constraints if button exists + if (dismissButton) { + [constraints addObjectsFromArray:@[ + [self->_dismissButton.trailingAnchor constraintEqualToAnchor:self->_container.trailingAnchor constant:-10], + [self->_dismissButton.centerYAnchor constraintEqualToAnchor:self->_label.centerYAnchor], + [self->_dismissButton.heightAnchor constraintEqualToConstant:22], + [self->_label.trailingAnchor constraintEqualToAnchor:self->_dismissButton.leadingAnchor constant:-10], + ]]; + } else { + [constraints addObject:[self->_label.trailingAnchor constraintEqualToAnchor:self->_container.trailingAnchor + constant:-10]]; + } + + [NSLayoutConstraint activateConstraints:constraints]; }); } RCT_EXPORT_METHOD( showMessage : (NSString *)message withColor : (NSNumber *__nonnull)color withBackgroundColor : (NSNumber *__nonnull) - backgroundColor) + backgroundColor withDismissButton : (NSNumber *)dismissButton) { - [self showMessage:message color:[RCTConvert UIColor:color] backgroundColor:[RCTConvert UIColor:backgroundColor]]; + [self showMessage:message + color:[RCTConvert UIColor:color] + backgroundColor:[RCTConvert UIColor:backgroundColor] + dismissButton:[dismissButton boolValue]]; } - RCT_EXPORT_METHOD(hide) { if (!RCTDevLoadingViewGetEnabled()) { @@ -211,7 +259,7 @@ - (void)showProgressMessage:(NSString *)message backgroundColor = [UIColor colorWithHue:0 saturation:0 brightness:0.98 alpha:1]; } - [self showMessage:message color:color backgroundColor:backgroundColor]; + [self showMessage:message color:color backgroundColor:backgroundColor dismissButton:false]; } - (void)showOfflineMessage @@ -225,7 +273,7 @@ - (void)showOfflineMessage } NSString *message = [NSString stringWithFormat:@"Connect to %@ to develop JavaScript.", RCT_PACKAGER_NAME]; - [self showMessage:message color:color backgroundColor:backgroundColor]; + [self showMessage:message color:color backgroundColor:backgroundColor dismissButton:false]; } - (BOOL)isDarkModeEnabled @@ -284,10 +332,16 @@ + (NSString *)moduleName + (void)setEnabled:(BOOL)enabled { } -- (void)showMessage:(NSString *)message color:(UIColor *)color backgroundColor:(UIColor *)backgroundColor +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton { } -- (void)showMessage:(NSString *)message withColor:(NSNumber *)color withBackgroundColor:(NSNumber *)backgroundColor +- (void)showMessage:(NSString *)message + withColor:(NSNumber *)color + withBackgroundColor:(NSNumber *)backgroundColor + withDismissButton:(NSNumber *)dismissButton { } - (void)showWithURL:(NSURL *)URL diff --git a/packages/react-native/React/DevSupport/RCTDevLoadingViewProtocol.h b/packages/react-native/React/DevSupport/RCTDevLoadingViewProtocol.h index ec9ed3a36c8187..6cea15d7533d5a 100644 --- a/packages/react-native/React/DevSupport/RCTDevLoadingViewProtocol.h +++ b/packages/react-native/React/DevSupport/RCTDevLoadingViewProtocol.h @@ -11,7 +11,10 @@ @protocol RCTDevLoadingViewProtocol + (void)setEnabled:(BOOL)enabled; -- (void)showMessage:(NSString *)message color:(UIColor *)color backgroundColor:(UIColor *)backgroundColor; +- (void)showMessage:(NSString *)message + color:(UIColor *)color + backgroundColor:(UIColor *)backgroundColor + dismissButton:(BOOL)dismissButton; - (void)showWithURL:(NSURL *)URL; - (void)updateProgress:(RCTLoadingProgress *)progress; - (void)hide; diff --git a/packages/react-native/ReactAndroid/api/ReactAndroid.api b/packages/react-native/ReactAndroid/api/ReactAndroid.api index 7998d196e3a99c..6c48dc4de97cbc 100644 --- a/packages/react-native/ReactAndroid/api/ReactAndroid.api +++ b/packages/react-native/ReactAndroid/api/ReactAndroid.api @@ -1878,7 +1878,7 @@ public final class com/facebook/react/devsupport/DefaultDevLoadingViewImplementa public fun (Lcom/facebook/react/devsupport/ReactInstanceDevHelper;)V public fun hide ()V public fun showMessage (Ljava/lang/String;)V - public fun showMessage (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V + public fun showMessage (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Boolean;)V public fun updateProgress (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V } @@ -2130,7 +2130,7 @@ public abstract interface class com/facebook/react/devsupport/interfaces/DevBund public abstract interface class com/facebook/react/devsupport/interfaces/DevLoadingViewManager { public abstract fun hide ()V public abstract fun showMessage (Ljava/lang/String;)V - public abstract fun showMessage (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;)V + public abstract fun showMessage (Ljava/lang/String;Ljava/lang/Double;Ljava/lang/Double;Ljava/lang/Boolean;)V public abstract fun updateProgress (Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;)V } diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt index ff366e685cf8b5..8271dfc12cc3a4 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt @@ -8,6 +8,7 @@ package com.facebook.react.devsupport import android.content.Context +import android.graphics.Color import android.graphics.Rect import android.view.Gravity import android.view.LayoutInflater @@ -33,14 +34,21 @@ public class DefaultDevLoadingViewImplementation( private var devLoadingPopup: PopupWindow? = null override fun showMessage(message: String) { - showMessage(message, color = null, backgroundColor = null) + showMessage(message, color = null, backgroundColor = null, dismissButton = false) } - override fun showMessage(message: String, color: Double?, backgroundColor: Double?) { + override fun showMessage( + message: String, + color: Double?, + backgroundColor: Double?, + dismissButton: Boolean?, + ) { if (!isEnabled) { return } - UiThreadUtil.runOnUiThread { showInternal(message, color, backgroundColor) } + UiThreadUtil.runOnUiThread { + showInternal(message, color, backgroundColor, dismissButton ?: false) + } } override fun updateProgress(status: String?, done: Int?, total: Int?) { @@ -63,7 +71,12 @@ public class DefaultDevLoadingViewImplementation( } } - private fun showInternal(message: String, color: Double?, backgroundColor: Double?) { + private fun showInternal( + message: String, + color: Double?, + backgroundColor: Double?, + dismissButton: Boolean, + ) { if (devLoadingPopup?.isShowing == true) { // already showing return @@ -86,24 +99,56 @@ public class DefaultDevLoadingViewImplementation( val topOffset = rectangle.top val inflater = currentActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - val view = inflater.inflate(R.layout.dev_loading_view, null) as TextView - view.text = message - if (color != null) { - view.setTextColor(color.toInt()) + val rootView = inflater.inflate(R.layout.dev_loading_view, null) as ViewGroup + val textView = rootView.findViewById(R.id.loading_text) + textView.text = message + + val dismissButtonView = rootView.findViewById(R.id.dismiss_button) + + if (dismissButton) { + dismissButtonView.visibility = android.view.View.VISIBLE + } else { + dismissButtonView.visibility = android.view.View.GONE } - if (backgroundColor != null) { - view.setBackgroundColor(backgroundColor.toInt()) + + // Use provided colors or defaults (matching iOS behavior) + val textColor = color?.toInt() ?: Color.WHITE + val bgColor = backgroundColor?.toInt() ?: Color.rgb(64, 64, 64) // Default grey + + textView.setTextColor(textColor) + rootView.setBackgroundColor(bgColor) + + if (dismissButton) { + dismissButtonView.setTextColor(textColor) + + // Darken the background color for the button + val red = (Color.red(bgColor) * 0.7).toInt() + val green = (Color.green(bgColor) * 0.7).toInt() + val blue = (Color.blue(bgColor) * 0.7).toInt() + val darkerColor = Color.rgb(red, green, blue) + + // Create rounded drawable for button + val drawable = android.graphics.drawable.GradientDrawable() + drawable.setColor(darkerColor) + drawable.cornerRadius = 15 * rootView.resources.displayMetrics.density + dismissButtonView.background = drawable + + dismissButtonView.setOnClickListener { hideInternal() } } - view.setOnClickListener { hideInternal() } + + // Allow tapping anywhere on the banner to dismiss + rootView.setOnClickListener { hideInternal() } + val popup = PopupWindow( - view, + rootView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) popup.showAtLocation(currentActivity.window.decorView, Gravity.NO_GRAVITY, 0, topOffset) - devLoadingView = view + devLoadingView = textView // Store the TextView for updateProgress() devLoadingPopup = popup + // TODO T164786028: Find out the root cause of the BadTokenException exception here } catch (e: WindowManager.BadTokenException) { FLog.e( diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevLoadingViewManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevLoadingViewManager.kt index 9366cfb3b3003c..600941aabb0e24 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevLoadingViewManager.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/interfaces/DevLoadingViewManager.kt @@ -11,7 +11,12 @@ package com.facebook.react.devsupport.interfaces public interface DevLoadingViewManager { public fun showMessage(message: String) - public fun showMessage(message: String, color: Double?, backgroundColor: Double?) + public fun showMessage( + message: String, + color: Double?, + backgroundColor: Double?, + dismissButton: Boolean?, + ) public fun updateProgress(status: String?, done: Int?, total: Int?) diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/devloading/DevLoadingModule.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/devloading/DevLoadingModule.kt index 78a3a84146d915..c840a622d7e5df 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/devloading/DevLoadingModule.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/modules/devloading/DevLoadingModule.kt @@ -30,9 +30,14 @@ internal class DevLoadingModule(reactContext: ReactApplicationContext) : } } - override fun showMessage(message: String, color: Double?, backgroundColor: Double?) { + override fun showMessage( + message: String, + color: Double?, + backgroundColor: Double?, + dismissButton: Boolean?, + ) { UiThreadUtil.runOnUiThread { - devLoadingViewManager?.showMessage(message, color, backgroundColor) + devLoadingViewManager?.showMessage(message, color, backgroundColor, dismissButton) } } diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml index b7e6b8cc0df6d2..02a3940c25eedc 100644 --- a/packages/react-native/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml +++ b/packages/react-native/ReactAndroid/src/main/res/devsupport/layout/dev_loading_view.xml @@ -1,17 +1,43 @@ - + + + + +