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 aa80dbe31ea8..2c3bdb9ca9b7 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,31 +7,8 @@ package com.facebook.react.devsupport -import android.animation.Animator -import android.animation.AnimatorListenerAdapter -import android.animation.AnimatorSet -import android.animation.ObjectAnimator import android.graphics.Color -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 -import com.facebook.react.common.ReactConstants import com.facebook.react.devsupport.interfaces.DevLoadingViewManager import java.util.Locale @@ -40,12 +17,9 @@ import java.util.Locale * the top of the screen. All methods are thread safe. */ public class DefaultDevLoadingViewImplementation( - private val reactInstanceDevHelper: ReactInstanceDevHelper, + reactInstanceDevHelper: ReactInstanceDevHelper, ) : DevLoadingViewManager { - private var devLoadingView: TextView? = null - private var devLoadingPopup: PopupWindow? = null - private var hideAnimator: Animator? = null - private var showTimeMs = 0L + private val banner = DevBanner(reactInstanceDevHelper) override fun showMessage(message: String) { showMessage(message, color = null, backgroundColor = null, dismissButton = false) @@ -61,7 +35,12 @@ public class DefaultDevLoadingViewImplementation( return } UiThreadUtil.runOnUiThread { - showInternal(message, color, backgroundColor, dismissButton ?: false) + banner.show( + message, + color?.toInt() ?: Color.WHITE, + backgroundColor?.toInt() ?: Color.rgb(64, 64, 64), // Default grey + dismissButton ?: false, + ) } } @@ -75,239 +54,21 @@ public class DefaultDevLoadingViewImplementation( else if (done != null && total != null && total > 0) String.format(Locale.getDefault(), " %.1f%%", done.toFloat() / total * 100) else "" - devLoadingView?.text = - "${status ?: "Loading"}${percentage}\u2026" // `...` character at the end + banner.updateMessage("${status ?: "Loading"}${percentage}…") // `...` character at the end } } public override fun hide() { if (isEnabled) { - UiThreadUtil.runOnUiThread { hideInternal() } + UiThreadUtil.runOnUiThread { banner.hide() } } } - private fun showInternal( - message: String, - color: Double?, - backgroundColor: Double?, - dismissButton: Boolean, - ) { - // 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( - ReactConstants.TAG, - "Unable to display loading message because react " + "activity isn't available", - ) - return - } - - try { - 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) - - // 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 - - pill.setOnClickListener { hideInternal() } - dismissButtonView.setOnClickListener { hideInternal() } - - val popup = - PopupWindow( - pill, - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT, - ) - 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) { - FLog.e( - ReactConstants.TAG, - "Unable to display loading message because react activity isn't active, message: $message", - ) - } - } - - 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 || 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/java/com/facebook/react/devsupport/DefaultPausedInDebuggerOverlayManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultPausedInDebuggerOverlayManager.kt new file mode 100644 index 000000000000..132357536043 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultPausedInDebuggerOverlayManager.kt @@ -0,0 +1,125 @@ +/* + * 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. + */ + +package com.facebook.react.devsupport + +import android.content.res.Configuration +import android.graphics.Color +import android.text.Layout +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.style.AbsoluteSizeSpan +import android.text.style.AlignmentSpan +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.view.View +import android.view.WindowManager +import com.facebook.react.R +import com.facebook.react.bridge.UiThreadUtil +import com.facebook.react.devsupport.interfaces.DevSupportManager +import com.facebook.react.devsupport.interfaces.PausedInDebuggerOverlayManager + +internal class DefaultPausedInDebuggerOverlayManager( + reactInstanceDevHelper: ReactInstanceDevHelper, +) : PausedInDebuggerOverlayManager { + // A dedicated banner, so the paused state neither replaces nor is replaced by the loading + // messages. + private val banner = PausedDevBanner(reactInstanceDevHelper) + + override fun showPausedInDebuggerOverlay( + message: String, + listener: DevSupportManager.PausedInDebuggerOverlayCommandListener, + ) { + UiThreadUtil.runOnUiThread { + banner.onResume = { listener.onResume() } + banner.show() + } + } + + override fun hidePausedInDebuggerOverlay() { + UiThreadUtil.runOnUiThread { banner.hide() } + } +} + +/** + * 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. + */ +internal class PausedDevBanner( + private val reactInstanceDevHelper: ReactInstanceDevHelper, +) : DevBanner(reactInstanceDevHelper) { + var onResume: (() -> Unit)? = null + + fun show() { + // 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. + val nightMode = + reactInstanceDevHelper.currentActivity + ?.resources + ?.configuration + ?.uiMode + ?.and(Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + show( + PAUSED_MESSAGE, + Color.BLACK, + if (nightMode) BACKGROUND_COLOR else BACKGROUND_COLOR_LIGHT, + dismissButton = false, + ) + } + + // Below the loading banners' windows, so they stay readable and tappable above the dimming while + // the app is paused. + override val windowLayoutType: Int = WindowManager.LayoutParams.TYPE_APPLICATION_PANEL + + override val dimsBackground: Boolean = true + + override val stackLevel: Int = 0 + + override val minHeightDp: Float = MIN_HEIGHT_DP + + override val labelPaddingDp: LabelPadding = + LabelPadding(top = 7f, start = (MIN_HEIGHT_DP - ICON_SIZE_DP) / 2, bottom = 7f, end = 19f) + + override val leadingIconRes: Int = R.drawable.ic_dev_loading_pause + + override val leadingIconSizeDp: Float = ICON_SIZE_DP + + override val messageGravity: Int = android.view.Gravity.START + + override fun formatMessage(pill: View, message: String, textColor: Int): CharSequence { + val subtitle = pill.resources.getString(R.string.catalyst_paused_tap_to_resume) + return SpannableStringBuilder(message).apply { + val start = length + 1 + append("\n").append(subtitle) + val flags = Spanned.SPAN_EXCLUSIVE_EXCLUSIVE + setSpan(AbsoluteSizeSpan(SUBTITLE_TEXT_SIZE_SP, true), start, length, flags) + setSpan(StyleSpan(android.graphics.Typeface.NORMAL), start, length, flags) + setSpan(ForegroundColorSpan(withAlpha(textColor, 0.6f)), start, length, flags) + setSpan(AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), start, length, flags) + } + } + + override fun contentDescription(pill: View, message: String): CharSequence = + "$message. ${pill.resources.getString(R.string.catalyst_paused_tap_to_resume)}." + + override fun onTap() { + onResume?.invoke() + } + + private companion object { + private const val PAUSED_MESSAGE = "App paused in debugger" + private const val MIN_HEIGHT_DP = 46f + private const val ICON_SIZE_DP = 24f + private const val SUBTITLE_TEXT_SIZE_SP = 10 + private val BACKGROUND_COLOR = Color.rgb(255, 236, 152) + private val BACKGROUND_COLOR_LIGHT = Color.rgb(255, 240, 173) + + private fun withAlpha(color: Int, alpha: Float): Int = + Color.argb((alpha * 255).toInt(), Color.red(color), Color.green(color), Color.blue(color)) + } +} diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevBanner.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevBanner.kt new file mode 100644 index 000000000000..42011aa78e94 --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevBanner.kt @@ -0,0 +1,429 @@ +/* + * 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. + */ + +package com.facebook.react.devsupport + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.graphics.Color +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.FrameLayout +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.common.ReactConstants + +/** + * A floating capsule at the top of the screen, in a popup window above the app, for messages shown + * during development. It sits below the status bar, keeps clear of the side insets, 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 open members. UI thread only. + */ +internal open class DevBanner(private val reactInstanceDevHelper: ReactInstanceDevHelper) { + /** + * The space between the capsule's edges and its label, or its leading icon where there is one. + */ + internal data class LabelPadding( + val top: Float, + val start: Float, + val bottom: Float, + val end: Float, + ) + + private var textView: TextView? = null + private var popup: PopupWindow? = null + private var pill: View? = null + private var hideAnimator: Animator? = null + private var showTimeMs = 0L + // The capsule's own top offset, before any banner stacked above it pushes it down. + private var topOffsetPx = 0 + + /** Whether the capsule is on screen or animating out. */ + val isVisible: Boolean + get() = popup?.isShowing == true + + // Hooks for subclasses, each with the loading banner's behavior by default. + + /** The window type of the banner's popup; sub panels sit above panels. */ + protected open val windowLayoutType: Int = WindowManager.LayoutParams.TYPE_APPLICATION_SUB_PANEL + + /** Whether the popup dims the app beneath the capsule and swallows touches on it. */ + protected open val dimsBackground: Boolean = false + + /** Banners with a lower level stack above those with a higher one. */ + open val stackLevel: Int = 1 + + /** The capsule's minimum height. */ + protected open val minHeightDp: Float = MIN_HEIGHT_DP + + protected open val labelPaddingDp: LabelPadding = LabelPadding(10f, 14f, 10f, 14f) + + /** A drawable shown before the label, or 0 for none. */ + protected open val leadingIconRes: Int = 0 + + protected open val leadingIconSizeDp: Float = 0f + + /** How the label's lines align within it. */ + protected open val messageGravity: Int = Gravity.CENTER + + /** The label's text for a message. */ + protected open fun formatMessage(pill: View, message: String, textColor: Int): CharSequence = + message + + /** The capsule's content description, or null to expose its children instead. */ + protected open fun contentDescription(pill: View, message: String): CharSequence? = null + + /** What a tap on the capsule does. */ + protected open fun onTap() { + hide() + } + + /** Shows the message, replacing any capsule already on screen. */ + fun show(message: String, textColor: Int, backgroundColor: Int, dismissButton: Boolean) { + // A new message replaces one that is still animating out. + hideAnimator?.cancel() + showTimeMs = SystemClock.uptimeMillis() + + val existingPopup = popup + val existingPill = pill + if (existingPopup?.isShowing == true && existingPill != null) { + applyContent(existingPill, message, textColor, backgroundColor, dismissButton) + return + } + + val currentActivity = reactInstanceDevHelper.currentActivity + if (currentActivity == null) { + FLog.e( + ReactConstants.TAG, + "Unable to display loading message because react " + "activity isn't available", + ) + return + } + + try { + 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, backgroundColor, dismissButton) + + // 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 iconView = pill.findViewById(R.id.leading_icon) + val dismissButtonWidth = + if (dismissButton) { + (dismissButtonView.layoutParams as ViewGroup.MarginLayoutParams).let { + it.width + it.marginStart + } + } else { + 0 + } + val iconWidth = + if (leadingIconRes != 0) { + (iconView.layoutParams as ViewGroup.MarginLayoutParams).let { it.width + it.marginEnd } + } else { + 0 + } + textView.maxWidth = + decorView.width - + 2 * sideInset - + pill.paddingStart - + pill.paddingEnd - + dismissButtonWidth - + iconWidth + + pill.setOnClickListener { onTap() } + dismissButtonView.setOnClickListener { hide() } + + topOffsetPx = if (topInset > 0) topInset + dpToPx(pill, 12f) else dpToPx(pill, 16f) + val content: View = + if (dimsBackground) { + // Dims the app and swallows touches while the banner says it cannot respond to them. + // The capsule sits inside, so it stays interactive. + FrameLayout(currentActivity).apply { + setBackgroundColor(DIMMING_COLOR) + isClickable = true + addView( + pill, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.TOP or Gravity.CENTER_HORIZONTAL, + ) + .apply { topMargin = topOffsetPx }, + ) + } + } else { + pill + } + val size = + if (dimsBackground) ViewGroup.LayoutParams.MATCH_PARENT + else ViewGroup.LayoutParams.WRAP_CONTENT + val popup = PopupWindow(content, size, size) + popup.windowLayoutType = windowLayoutType + if (dimsBackground) { + // The dimming covers the whole screen, system bars included, which also keeps the + // capsule's top offset in screen coordinates like the other banners'. + popup.isClippingEnabled = false + } + this.pill = pill + popup.showAtLocation( + decorView, + Gravity.TOP or Gravity.CENTER_HORIZONTAL, + 0, + if (dimsBackground) 0 else topOffsetPx + stackOffsetPx(), + ) + // 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 = content.rootView + (popupRoot.layoutParams as? WindowManager.LayoutParams)?.let { + it.setCanPlayMoveAnimation(false) + currentActivity.windowManager.updateViewLayout(popupRoot, it) + } + } + this.textView = textView + this.popup = popup + visibleBanners.add(this) + // The capsule's height is known once it has been laid out. + pill.post { updateOtherBanners() } + animateIn(pill) + if (content !== pill) { + content.alpha = 0f + content.animate().alpha(1f).setDuration(ANIMATION_DURATION_MS).start() + } + + // TODO T164786028: Find out the root cause of the BadTokenException exception here + } catch (e: WindowManager.BadTokenException) { + FLog.e( + ReactConstants.TAG, + "Unable to display loading message because react activity isn't active, message: $message", + ) + } + } + + /** + * Replaces the text of the capsule on screen, for updates that arrive quickly such as progress. + */ + fun updateMessage(message: CharSequence) { + textView?.text = message + } + + /** Animates the capsule out, after it has been on screen long enough to read. */ + fun hide() { + val popup = popup ?: return + val pill = pill ?: return + if (!popup.isShowing || hideAnimator != null) { + return + } + setHiddenPivot(pill) + val animator = + AnimatorSet().apply { + val animators = + mutableListOf( + ObjectAnimator.ofFloat(pill, View.ALPHA, 0f), + ObjectAnimator.ofFloat(pill, View.SCALE_X, HIDDEN_SCALE), + ObjectAnimator.ofFloat(pill, View.SCALE_Y, HIDDEN_SCALE), + ) + if (popup.contentView !== pill) { + animators.add(ObjectAnimator.ofFloat(popup.contentView, View.ALPHA, 0f)) + } + playTogether(animators) + // 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() + popup.contentView.animate().alpha(1f).setDuration(ANIMATION_DURATION_MS).start() + return + } + if (popup.isShowing) { + popup.dismiss() + } + if (this@DevBanner.popup === popup) { + this@DevBanner.popup = null + textView = null + this@DevBanner.pill = null + } + visibleBanners.remove(this@DevBanner) + updateOtherBanners() + } + } + ) + } + hideAnimator = animator + animator.start() + } + + 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() + } + pill.minimumHeight = dpToPx(pill, minHeightDp) + val textView = pill.findViewById(R.id.loading_text) + textView.text = formatMessage(pill, message, textColor) + textView.setTextColor(textColor) + textView.gravity = messageGravity + val padding = labelPaddingDp + textView.setPadding(0, dpToPx(pill, padding.top), 0, dpToPx(pill, padding.bottom)) + + val iconView = pill.findViewById(R.id.leading_icon) + if (leadingIconRes != 0) { + iconView.setImageResource(leadingIconRes) + iconView.layoutParams = + iconView.layoutParams.apply { + width = dpToPx(pill, leadingIconSizeDp) + height = dpToPx(pill, leadingIconSizeDp) + } + iconView.visibility = View.VISIBLE + } else { + iconView.visibility = View.GONE + } + pill.contentDescription = contentDescription(pill, message) + + 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( + dpToPx(pill, padding.start), + pill.paddingTop, + dpToPx(pill, if (dismissButton) 9f else padding.end), + 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 stackOffsetPx(): Int { + val pill = pill ?: return 0 + return visibleBanners.sumOf { + val other = it.pill + if (it !== this && other != null && it.stackLevel < stackLevel) { + other.height + dpToPx(pill, STACK_SPACING_DP) + } else { + 0 + } + } + } + + // Moves the capsule under the banners stacked above it. A dimming banner is never pushed down. + private fun updatePlacement() { + val popup = popup ?: return + if (!popup.isShowing || dimsBackground) { + return + } + popup.update(0, topOffsetPx + stackOffsetPx(), -1, -1) + } + + private fun updateOtherBanners() { + for (banner in visibleBanners.toList()) { + if (banner !== this) { + banner.updatePlacement() + } + } + } + + private 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 const val MIN_HEIGHT_DP = 40f + private const val STACK_SPACING_DP = 12f + private val DIMMING_COLOR = Color.argb(51, 0, 0, 0) + + // The banners currently shown, so one can stack below another. Touched on the UI thread only. + private val visibleBanners = mutableListOf() + + 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/java/com/facebook/react/devsupport/DevSupportManagerBase.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt index 8abdd78fad99..80d7d7678985 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DevSupportManagerBase.kt @@ -32,7 +32,6 @@ import android.widget.ListAdapter import android.widget.TextView import android.widget.Toast import androidx.annotation.UiThread -import androidx.core.util.Supplier import com.facebook.common.logging.FLog import com.facebook.react.R import com.facebook.react.bridge.DefaultJSExceptionHandler @@ -221,16 +220,7 @@ public abstract class DevSupportManagerBase( devLoadingViewManager = DefaultDevLoadingViewImplementation(reactInstanceDevHelper) } if (pausedInDebuggerOverlayManager == null) { - pausedInDebuggerOverlayManager = - PausedInDebuggerOverlayDialogManager( - Supplier { - val context = reactInstanceDevHelper.currentActivity - if (context == null || context.isFinishing) { - return@Supplier null - } - context - }, - ) + pausedInDebuggerOverlayManager = DefaultPausedInDebuggerOverlayManager(reactInstanceDevHelper) } if ( ReactNativeNewArchitectureFeatureFlags.enableBridgelessArchitecture() && diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PausedInDebuggerOverlayDialogManager.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PausedInDebuggerOverlayDialogManager.kt deleted file mode 100644 index 156e12c60d54..000000000000 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/PausedInDebuggerOverlayDialogManager.kt +++ /dev/null @@ -1,70 +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. - */ - -package com.facebook.react.devsupport - -import android.app.Dialog -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.Gravity -import android.view.LayoutInflater -import android.view.View -import android.view.WindowManager -import android.widget.TextView -import androidx.core.util.Supplier -import com.facebook.react.R -import com.facebook.react.bridge.UiThreadUtil -import com.facebook.react.devsupport.interfaces.DevSupportManager -import com.facebook.react.devsupport.interfaces.PausedInDebuggerOverlayManager - -internal class PausedInDebuggerOverlayDialogManager( - private val contextSupplier: Supplier, -) : PausedInDebuggerOverlayManager { - private var pausedInDebuggerDialog: Dialog? = null - - override fun showPausedInDebuggerOverlay( - message: String, - listener: DevSupportManager.PausedInDebuggerOverlayCommandListener, - ) { - UiThreadUtil.runOnUiThread { - pausedInDebuggerDialog?.dismiss() - val context = contextSupplier.get() ?: return@runOnUiThread - - val dialogView: View = - LayoutInflater.from(context).inflate(R.layout.paused_in_debugger_view, null) - dialogView.findViewById(R.id.button).setOnClickListener { listener.onResume() } - dialogView.findViewById(R.id.button_text).text = message - - pausedInDebuggerDialog = - Dialog(context, R.style.NoAnimationDialog).apply { - setContentView(dialogView) - setCancelable(false) - } - - pausedInDebuggerDialog?.window?.let { dialogWindow -> - val layoutParams: WindowManager.LayoutParams = dialogWindow.attributes - layoutParams.dimAmount = 0.2f - - dialogWindow.attributes = layoutParams - dialogWindow.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) - dialogWindow.setGravity(Gravity.TOP) - dialogWindow.setElevation(0f) - dialogWindow.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) - dialogWindow.setBackgroundDrawableResource(R.drawable.paused_in_debugger_background) - } - pausedInDebuggerDialog?.show() - } - } - - override fun hidePausedInDebuggerOverlay() { - UiThreadUtil.runOnUiThread { - pausedInDebuggerDialog?.dismiss() - pausedInDebuggerDialog = null - } - } -} diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-hdpi/ic_resume.png b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-hdpi/ic_resume.png deleted file mode 100644 index e76d9252a8fe..000000000000 Binary files a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-hdpi/ic_resume.png and /dev/null differ diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-mdpi/ic_resume.png b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-mdpi/ic_resume.png deleted file mode 100644 index f0100879454b..000000000000 Binary files a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-mdpi/ic_resume.png and /dev/null differ diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xhdpi/ic_resume.png b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xhdpi/ic_resume.png deleted file mode 100644 index 5d03ade4fe6a..000000000000 Binary files a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xhdpi/ic_resume.png and /dev/null differ diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxhdpi/ic_resume.png b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxhdpi/ic_resume.png deleted file mode 100644 index 732b8f46998b..000000000000 Binary files a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxhdpi/ic_resume.png and /dev/null differ diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxxhdpi/ic_resume.png b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxxhdpi/ic_resume.png deleted file mode 100644 index 6f48a9b9517c..000000000000 Binary files a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable-xxxhdpi/ic_resume.png and /dev/null differ diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_pause.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_pause.xml new file mode 100644 index 000000000000..5d6be7d9434e --- /dev/null +++ b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/ic_dev_loading_pause.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_background.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_background.xml deleted file mode 100644 index 5f1b8809a572..000000000000 --- a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_background.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_dialog_background.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_dialog_background.xml deleted file mode 100644 index d6e19679c71e..000000000000 --- a/packages/react-native/ReactAndroid/src/main/res/devsupport/drawable/paused_in_debugger_dialog_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - 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 2abf46f2a19a..a69a016c0c9b 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 @@ -11,6 +11,15 @@ android:paddingEnd="14dp" > + + - - - - diff --git a/packages/react-native/ReactAndroid/src/main/res/devsupport/values/strings.xml b/packages/react-native/ReactAndroid/src/main/res/devsupport/values/strings.xml index 9621bacc928f..ced4497102f2 100644 --- a/packages/react-native/ReactAndroid/src/main/res/devsupport/values/strings.xml +++ b/packages/react-native/ReactAndroid/src/main/res/devsupport/values/strings.xml @@ -37,4 +37,5 @@ React Native Dev Menu Running %1$s Dismiss ✕ + Tap to resume