Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand All @@ -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<TextView>(R.id.loading_text)
textView.text = message

val dismissButtonView = rootView.findViewById<android.widget.Button>(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<TextView>(R.id.loading_text)
val dismissButtonView = pill.findViewById<View>(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) {
Expand All @@ -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<TextView>(R.id.loading_text)
textView.text = message
textView.setTextColor(textColor)

val dismissButtonView = pill.findViewById<ImageView>(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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
</vector>
Original file line number Diff line number Diff line change
@@ -1,43 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:id="@+id/loading_pill"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="40dp"
android:orientation="horizontal"
android:background="#404040"
android:gravity="center_vertical"
android:paddingStart="14dp"
android:paddingEnd="14dp"
>

<TextView
android:id="@+id/loading_text"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_gravity="center_vertical"
android:gravity="center"
android:paddingTop="10dp"
android:paddingBottom="10dp"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:textSize="12sp"
android:fontFamily="sans-serif"
android:lineSpacingExtra="2dp"
android:textSize="12.5sp"
android:fontFamily="sans-serif-medium"
/>

<Button
<ImageView
android:id="@+id/dismiss_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="10dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/catalyst_loading_dismiss"
android:textAllCaps="false"
android:textSize="11sp"
android:fontFamily="sans-serif"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_marginStart="8dp"
android:padding="5dp"
android:src="@drawable/ic_dev_loading_dismiss"
android:contentDescription="@string/catalyst_loading_dismiss"
/>

</LinearLayout>
Loading