From 6fa0b40ed832098e4379d13fdd6f0d44d749eaca Mon Sep 17 00:00:00 2001 From: Alex Hunt Date: Thu, 8 Oct 2026 15:18:02 +0200 Subject: [PATCH] Redesign DevLoadingView (Android) --- .../DefaultDevLoadingViewImplementation.kt | 253 ++++++++++++++---- .../drawable/ic_dev_loading_dismiss.xml | 10 + .../devsupport/layout/dev_loading_view.xml | 41 ++- 3 files changed, 221 insertions(+), 83 deletions(-) create mode 100644 packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_dismiss.xml 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 ad783e8bb494..aa80dbe31ea8 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 @@ -7,15 +7,27 @@ package com.facebook.react.devsupport -import android.content.Context +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.graphics.Color -import android.graphics.Rect +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.SystemClock +import android.util.TypedValue import android.view.Gravity import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.view.WindowManager +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.widget.ImageView import android.widget.PopupWindow import android.widget.TextView +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.facebook.common.logging.FLog import com.facebook.react.R import com.facebook.react.bridge.UiThreadUtil @@ -24,14 +36,16 @@ import com.facebook.react.devsupport.interfaces.DevLoadingViewManager import java.util.Locale /** - * Default implementation of Dev Loading View Manager to display loading messages on top of the - * screen. All methods are thread safe. + * Default implementation of Dev Loading View Manager to display loading messages in a capsule at + * the top of the screen. All methods are thread safe. */ public class DefaultDevLoadingViewImplementation( private val reactInstanceDevHelper: ReactInstanceDevHelper, ) : DevLoadingViewManager { private var devLoadingView: TextView? = null private var devLoadingPopup: PopupWindow? = null + private var hideAnimator: Animator? = null + private var showTimeMs = 0L override fun showMessage(message: String) { showMessage(message, color = null, backgroundColor = null, dismissButton = false) @@ -78,10 +92,19 @@ public class DefaultDevLoadingViewImplementation( backgroundColor: Double?, dismissButton: Boolean, ) { - if (devLoadingPopup?.isShowing == true) { - // already showing + // A new message replaces one that is still animating out. + hideAnimator?.cancel() + showTimeMs = SystemClock.uptimeMillis() + + val textColor = color?.toInt() ?: Color.WHITE + val bgColor = backgroundColor?.toInt() ?: Color.rgb(64, 64, 64) // Default grey + + val existingPopup = devLoadingPopup + if (existingPopup?.isShowing == true) { + applyContent(existingPopup.contentView, message, textColor, bgColor, dismissButton) return } + val currentActivity = reactInstanceDevHelper.currentActivity if (currentActivity == null) { FLog.e( @@ -91,64 +114,61 @@ public class DefaultDevLoadingViewImplementation( return } - // PopupWindow#showAtLocation uses absolute screen position. In order for - // loading view to be placed below status bar (if the status bar is present) we need to pass - // an appropriate Y offset. try { - val rectangle = Rect() - currentActivity.window.decorView.getWindowVisibleDisplayFrame(rectangle) - val topOffset = rectangle.top - val inflater = - currentActivity.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater - 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 - } - - // 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) + val decorView = currentActivity.window.decorView + val inflater = LayoutInflater.from(currentActivity) + val pill = inflater.inflate(R.layout.dev_loading_view, null) as ViewGroup + applyContent(pill, message, textColor, bgColor, dismissButton) - // Create rounded drawable for button - val drawable = android.graphics.drawable.GradientDrawable() - drawable.setColor(darkerColor) - drawable.cornerRadius = 15 * rootView.resources.displayMetrics.density - dismissButtonView.background = drawable + // Sits 12dp below a top inset such as the status bar, or 16dp from the top edge without one, + // centered and kept clear of the side insets with a 16dp margin. + val insets = + ViewCompat.getRootWindowInsets(decorView) + ?.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + val topInset = insets?.top ?: 0 + val sideInset = maxOf(insets?.left ?: 0, insets?.right ?: 0) + dpToPx(pill, 16f) + val textView = pill.findViewById(R.id.loading_text) + val dismissButtonView = pill.findViewById(R.id.dismiss_button) + val dismissButtonWidth = + if (dismissButton) { + (dismissButtonView.layoutParams as ViewGroup.MarginLayoutParams).let { + it.width + it.marginStart + } + } else { + 0 + } + textView.maxWidth = + decorView.width - 2 * sideInset - pill.paddingStart - pill.paddingEnd - dismissButtonWidth - dismissButtonView.setOnClickListener { hideInternal() } - } - - // Allow tapping anywhere on the banner to dismiss - rootView.setOnClickListener { hideInternal() } + pill.setOnClickListener { hideInternal() } + dismissButtonView.setOnClickListener { hideInternal() } val popup = PopupWindow( - rootView, - ViewGroup.LayoutParams.MATCH_PARENT, + pill, + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, ) - popup.showAtLocation(currentActivity.window.decorView, Gravity.NO_GRAVITY, 0, topOffset) - devLoadingView = textView // Store the TextView for updateProgress() + popup.showAtLocation( + decorView, + Gravity.TOP or Gravity.CENTER_HORIZONTAL, + 0, + if (topInset > 0) topInset + dpToPx(pill, 12f) else dpToPx(pill, 16f), + ) + // A new message resizes the window about its center. Without this, the system animates that + // move, so the capsule grows from its old left edge and then slides back to center. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val popupRoot = pill.rootView + (popupRoot.layoutParams as? WindowManager.LayoutParams)?.let { + it.setCanPlayMoveAnimation(false) + currentActivity.windowManager.updateViewLayout(popupRoot, it) + } + } + devLoadingView = textView devLoadingPopup = popup + animateIn(pill) // TODO T164786028: Find out the root cause of the BadTokenException exception here } catch (e: WindowManager.BadTokenException) { @@ -159,20 +179,135 @@ public class DefaultDevLoadingViewImplementation( } } + private fun applyContent( + pill: View, + message: String, + textColor: Int, + bgColor: Int, + dismissButton: Boolean, + ) { + pill.background = + GradientDrawable().apply { + setColor(bgColor) + // Larger than any banner height, so the corners always form a capsule. + cornerRadius = dpToPx(pill, 1000f).toFloat() + } + val textView = pill.findViewById(R.id.loading_text) + textView.text = message + textView.setTextColor(textColor) + + val dismissButtonView = pill.findViewById(R.id.dismiss_button) + dismissButtonView.visibility = if (dismissButton) View.VISIBLE else View.GONE + // The round button sits closer to the capsule's end than the text would. + pill.setPaddingRelative( + pill.paddingStart, + pill.paddingTop, + dpToPx(pill, if (dismissButton) 9f else 14f), + pill.paddingBottom, + ) + dismissButtonView.setColorFilter(textColor) + dismissButtonView.background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor( + Color.argb(51, Color.red(textColor), Color.green(textColor), Color.blue(textColor)) + ) + } + } + + private fun animateIn(pill: View) { + pill.alpha = 0f + pill.scaleX = HIDDEN_SCALE + pill.scaleY = HIDDEN_SCALE + pill.post { + setHiddenPivot(pill) + pill + .animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(ANIMATION_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + } + } + private fun hideInternal() { val popup = devLoadingPopup ?: return - if (popup.isShowing == true) { - popup.dismiss() - devLoadingPopup = null - devLoadingView = null + if (!popup.isShowing || hideAnimator != null) { + return } + val pill = popup.contentView + setHiddenPivot(pill) + val animator = + AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(pill, View.ALPHA, 0f), + ObjectAnimator.ofFloat(pill, View.SCALE_X, HIDDEN_SCALE), + ObjectAnimator.ofFloat(pill, View.SCALE_Y, HIDDEN_SCALE), + ) + // Keeps a message that is shown and hidden in quick succession, such as a fast refresh, + // on screen long enough to read. + startDelay = maxOf(0L, MIN_PRESENTED_TIME_MS - (SystemClock.uptimeMillis() - showTimeMs)) + duration = ANIMATION_DURATION_MS + interpolator = AccelerateInterpolator() + addListener( + object : AnimatorListenerAdapter() { + private var canceled = false + + override fun onAnimationCancel(animation: Animator) { + canceled = true + } + + override fun onAnimationEnd(animation: Animator) { + hideAnimator = null + // A new message canceled the hide, so the banner stays and returns to full size. + if (canceled) { + pill + .animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(ANIMATION_DURATION_MS) + .setInterpolator(DecelerateInterpolator()) + .start() + return + } + if (popup.isShowing) { + popup.dismiss() + } + if (devLoadingPopup === popup) { + devLoadingPopup = null + devLoadingView = null + } + } + } + ) + } + hideAnimator = animator + animator.start() } public companion object { + private const val ANIMATION_DURATION_MS = 200L + private const val MIN_PRESENTED_TIME_MS = 600L + private const val HIDDEN_SCALE = 0.85f + // The hidden state scales about a point this fraction of the banner's height above its center. + private const val HIDDEN_ANCHOR_OFFSET = 0.05f + private var isEnabled = true public fun setDevLoadingEnabled(enabled: Boolean) { isEnabled = enabled } + + private fun setHiddenPivot(pill: View) { + pill.pivotX = pill.width / 2f + pill.pivotY = pill.height * (0.5f - HIDDEN_ANCHOR_OFFSET) + } + + private fun dpToPx(view: View, dp: Float): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, view.resources.displayMetrics) + .toInt() } } diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_dismiss.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_dismiss.xml new file mode 100644 index 000000000000..c08624568f92 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_dismiss.xml @@ -0,0 +1,10 @@ + + + + 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 02a3940c25ee..2abf46f2a19a 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,43 +1,36 @@ -