77
88package com.facebook.react.devsupport
99
10- import android.content.Context
10+ import android.animation.Animator
11+ import android.animation.AnimatorListenerAdapter
12+ import android.animation.AnimatorSet
13+ import android.animation.ObjectAnimator
1114import android.graphics.Color
12- import android.graphics.Rect
15+ import android.graphics.drawable.GradientDrawable
16+ import android.os.Build
17+ import android.os.SystemClock
18+ import android.util.TypedValue
1319import android.view.Gravity
1420import android.view.LayoutInflater
21+ import android.view.View
1522import android.view.ViewGroup
1623import android.view.WindowManager
24+ import android.view.animation.AccelerateInterpolator
25+ import android.view.animation.DecelerateInterpolator
26+ import android.widget.ImageView
1727import android.widget.PopupWindow
1828import android.widget.TextView
29+ import androidx.core.view.ViewCompat
30+ import androidx.core.view.WindowInsetsCompat
1931import com.facebook.common.logging.FLog
2032import com.facebook.react.R
2133import com.facebook.react.bridge.UiThreadUtil
@@ -24,14 +36,16 @@ import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
2436import java.util.Locale
2537
2638/* *
27- * Default implementation of Dev Loading View Manager to display loading messages on top of the
28- * screen. All methods are thread safe.
39+ * Default implementation of Dev Loading View Manager to display loading messages in a capsule at
40+ * the top of the screen. All methods are thread safe.
2941 */
3042public class DefaultDevLoadingViewImplementation (
3143 private val reactInstanceDevHelper : ReactInstanceDevHelper ,
3244) : DevLoadingViewManager {
3345 private var devLoadingView: TextView ? = null
3446 private var devLoadingPopup: PopupWindow ? = null
47+ private var hideAnimator: Animator ? = null
48+ private var showTimeMs = 0L
3549
3650 override fun showMessage (message : String ) {
3751 showMessage(message, color = null , backgroundColor = null , dismissButton = false )
@@ -78,10 +92,19 @@ public class DefaultDevLoadingViewImplementation(
7892 backgroundColor : Double? ,
7993 dismissButton : Boolean ,
8094 ) {
81- if (devLoadingPopup?.isShowing == true ) {
82- // already showing
95+ // A new message replaces one that is still animating out.
96+ hideAnimator?.cancel()
97+ showTimeMs = SystemClock .uptimeMillis()
98+
99+ val textColor = color?.toInt() ? : Color .WHITE
100+ val bgColor = backgroundColor?.toInt() ? : Color .rgb(64 , 64 , 64 ) // Default grey
101+
102+ val existingPopup = devLoadingPopup
103+ if (existingPopup?.isShowing == true ) {
104+ applyContent(existingPopup.contentView, message, textColor, bgColor, dismissButton)
83105 return
84106 }
107+
85108 val currentActivity = reactInstanceDevHelper.currentActivity
86109 if (currentActivity == null ) {
87110 FLog .e(
@@ -91,64 +114,61 @@ public class DefaultDevLoadingViewImplementation(
91114 return
92115 }
93116
94- // PopupWindow#showAtLocation uses absolute screen position. In order for
95- // loading view to be placed below status bar (if the status bar is present) we need to pass
96- // an appropriate Y offset.
97117 try {
98- val rectangle = Rect ()
99- currentActivity.window.decorView.getWindowVisibleDisplayFrame(rectangle)
100- val topOffset = rectangle.top
101- val inflater =
102- currentActivity.getSystemService(Context .LAYOUT_INFLATER_SERVICE ) as LayoutInflater
103- val rootView = inflater.inflate(R .layout.dev_loading_view, null ) as ViewGroup
104- val textView = rootView.findViewById<TextView >(R .id.loading_text)
105- textView.text = message
106-
107- val dismissButtonView = rootView.findViewById< android.widget.Button > (R .id.dismiss_button)
108-
109- if (dismissButton) {
110- dismissButtonView.visibility = android.view.View .VISIBLE
111- } else {
112- dismissButtonView.visibility = android.view.View .GONE
113- }
114-
115- // Use provided colors or defaults (matching iOS behavior)
116- val textColor = color?.toInt() ? : Color .WHITE
117- val bgColor = backgroundColor?.toInt() ? : Color .rgb(64 , 64 , 64 ) // Default grey
118-
119- textView.setTextColor(textColor)
120- rootView.setBackgroundColor(bgColor)
121-
122- if (dismissButton) {
123- dismissButtonView.setTextColor(textColor)
124-
125- // Darken the background color for the button
126- val red = (Color .red(bgColor) * 0.7 ).toInt()
127- val green = (Color .green(bgColor) * 0.7 ).toInt()
128- val blue = (Color .blue(bgColor) * 0.7 ).toInt()
129- val darkerColor = Color .rgb(red, green, blue)
118+ val decorView = currentActivity.window.decorView
119+ val inflater = LayoutInflater .from(currentActivity)
120+ val pill = inflater.inflate(R .layout.dev_loading_view, null ) as ViewGroup
121+ applyContent(pill, message, textColor, bgColor, dismissButton)
130122
131- // Create rounded drawable for button
132- val drawable = android.graphics.drawable.GradientDrawable ()
133- drawable.setColor(darkerColor)
134- drawable.cornerRadius = 15 * rootView.resources.displayMetrics.density
135- dismissButtonView.background = drawable
123+ // Sits 12dp below a top inset such as the status bar, or 16dp from the top edge without one,
124+ // centered and kept clear of the side insets with a 16dp margin.
125+ val insets =
126+ ViewCompat .getRootWindowInsets(decorView)
127+ ?.getInsets(
128+ WindowInsetsCompat .Type .systemBars() or WindowInsetsCompat .Type .displayCutout()
129+ )
130+ val topInset = insets?.top ? : 0
131+ val sideInset = maxOf(insets?.left ? : 0 , insets?.right ? : 0 ) + dpToPx(pill, 16f )
132+ val textView = pill.findViewById<TextView >(R .id.loading_text)
133+ val dismissButtonView = pill.findViewById<View >(R .id.dismiss_button)
134+ val dismissButtonWidth =
135+ if (dismissButton) {
136+ (dismissButtonView.layoutParams as ViewGroup .MarginLayoutParams ).let {
137+ it.width + it.marginStart
138+ }
139+ } else {
140+ 0
141+ }
142+ textView.maxWidth =
143+ decorView.width - 2 * sideInset - pill.paddingStart - pill.paddingEnd - dismissButtonWidth
136144
137- dismissButtonView.setOnClickListener { hideInternal() }
138- }
139-
140- // Allow tapping anywhere on the banner to dismiss
141- rootView.setOnClickListener { hideInternal() }
145+ pill.setOnClickListener { hideInternal() }
146+ dismissButtonView.setOnClickListener { hideInternal() }
142147
143148 val popup =
144149 PopupWindow (
145- rootView ,
146- ViewGroup .LayoutParams .MATCH_PARENT ,
150+ pill ,
151+ ViewGroup .LayoutParams .WRAP_CONTENT ,
147152 ViewGroup .LayoutParams .WRAP_CONTENT ,
148153 )
149- popup.showAtLocation(currentActivity.window.decorView, Gravity .NO_GRAVITY , 0 , topOffset)
150- devLoadingView = textView // Store the TextView for updateProgress()
154+ popup.showAtLocation(
155+ decorView,
156+ Gravity .TOP or Gravity .CENTER_HORIZONTAL ,
157+ 0 ,
158+ if (topInset > 0 ) topInset + dpToPx(pill, 12f ) else dpToPx(pill, 16f ),
159+ )
160+ // A new message resizes the window about its center. Without this, the system animates that
161+ // move, so the capsule grows from its old left edge and then slides back to center.
162+ if (Build .VERSION .SDK_INT >= Build .VERSION_CODES .TIRAMISU ) {
163+ val popupRoot = pill.rootView
164+ (popupRoot.layoutParams as ? WindowManager .LayoutParams )?.let {
165+ it.setCanPlayMoveAnimation(false )
166+ currentActivity.windowManager.updateViewLayout(popupRoot, it)
167+ }
168+ }
169+ devLoadingView = textView
151170 devLoadingPopup = popup
171+ animateIn(pill)
152172
153173 // TODO T164786028: Find out the root cause of the BadTokenException exception here
154174 } catch (e: WindowManager .BadTokenException ) {
@@ -159,20 +179,135 @@ public class DefaultDevLoadingViewImplementation(
159179 }
160180 }
161181
182+ private fun applyContent (
183+ pill : View ,
184+ message : String ,
185+ textColor : Int ,
186+ bgColor : Int ,
187+ dismissButton : Boolean ,
188+ ) {
189+ pill.background =
190+ GradientDrawable ().apply {
191+ setColor(bgColor)
192+ // Larger than any banner height, so the corners always form a capsule.
193+ cornerRadius = dpToPx(pill, 1000f ).toFloat()
194+ }
195+ val textView = pill.findViewById<TextView >(R .id.loading_text)
196+ textView.text = message
197+ textView.setTextColor(textColor)
198+
199+ val dismissButtonView = pill.findViewById<ImageView >(R .id.dismiss_button)
200+ dismissButtonView.visibility = if (dismissButton) View .VISIBLE else View .GONE
201+ // The round button sits closer to the capsule's end than the text would.
202+ pill.setPaddingRelative(
203+ pill.paddingStart,
204+ pill.paddingTop,
205+ dpToPx(pill, if (dismissButton) 9f else 14f ),
206+ pill.paddingBottom,
207+ )
208+ dismissButtonView.setColorFilter(textColor)
209+ dismissButtonView.background =
210+ GradientDrawable ().apply {
211+ shape = GradientDrawable .OVAL
212+ setColor(
213+ Color .argb(51 , Color .red(textColor), Color .green(textColor), Color .blue(textColor))
214+ )
215+ }
216+ }
217+
218+ private fun animateIn (pill : View ) {
219+ pill.alpha = 0f
220+ pill.scaleX = HIDDEN_SCALE
221+ pill.scaleY = HIDDEN_SCALE
222+ pill.post {
223+ setHiddenPivot(pill)
224+ pill
225+ .animate()
226+ .alpha(1f )
227+ .scaleX(1f )
228+ .scaleY(1f )
229+ .setDuration(ANIMATION_DURATION_MS )
230+ .setInterpolator(DecelerateInterpolator ())
231+ .start()
232+ }
233+ }
234+
162235 private fun hideInternal () {
163236 val popup = devLoadingPopup ? : return
164- if (popup.isShowing == true ) {
165- popup.dismiss()
166- devLoadingPopup = null
167- devLoadingView = null
237+ if (! popup.isShowing || hideAnimator != null ) {
238+ return
168239 }
240+ val pill = popup.contentView
241+ setHiddenPivot(pill)
242+ val animator =
243+ AnimatorSet ().apply {
244+ playTogether(
245+ ObjectAnimator .ofFloat(pill, View .ALPHA , 0f ),
246+ ObjectAnimator .ofFloat(pill, View .SCALE_X , HIDDEN_SCALE ),
247+ ObjectAnimator .ofFloat(pill, View .SCALE_Y , HIDDEN_SCALE ),
248+ )
249+ // Keeps a message that is shown and hidden in quick succession, such as a fast refresh,
250+ // on screen long enough to read.
251+ startDelay = maxOf(0L , MIN_PRESENTED_TIME_MS - (SystemClock .uptimeMillis() - showTimeMs))
252+ duration = ANIMATION_DURATION_MS
253+ interpolator = AccelerateInterpolator ()
254+ addListener(
255+ object : AnimatorListenerAdapter () {
256+ private var canceled = false
257+
258+ override fun onAnimationCancel (animation : Animator ) {
259+ canceled = true
260+ }
261+
262+ override fun onAnimationEnd (animation : Animator ) {
263+ hideAnimator = null
264+ // A new message canceled the hide, so the banner stays and returns to full size.
265+ if (canceled) {
266+ pill
267+ .animate()
268+ .alpha(1f )
269+ .scaleX(1f )
270+ .scaleY(1f )
271+ .setDuration(ANIMATION_DURATION_MS )
272+ .setInterpolator(DecelerateInterpolator ())
273+ .start()
274+ return
275+ }
276+ if (popup.isShowing) {
277+ popup.dismiss()
278+ }
279+ if (devLoadingPopup == = popup) {
280+ devLoadingPopup = null
281+ devLoadingView = null
282+ }
283+ }
284+ }
285+ )
286+ }
287+ hideAnimator = animator
288+ animator.start()
169289 }
170290
171291 public companion object {
292+ private const val ANIMATION_DURATION_MS = 200L
293+ private const val MIN_PRESENTED_TIME_MS = 600L
294+ private const val HIDDEN_SCALE = 0.85f
295+ // The hidden state scales about a point this fraction of the banner's height above its center.
296+ private const val HIDDEN_ANCHOR_OFFSET = 0.05f
297+
172298 private var isEnabled = true
173299
174300 public fun setDevLoadingEnabled (enabled : Boolean ) {
175301 isEnabled = enabled
176302 }
303+
304+ private fun setHiddenPivot (pill : View ) {
305+ pill.pivotX = pill.width / 2f
306+ pill.pivotY = pill.height * (0.5f - HIDDEN_ANCHOR_OFFSET )
307+ }
308+
309+ private fun dpToPx (view : View , dp : Float ): Int =
310+ TypedValue .applyDimension(TypedValue .COMPLEX_UNIT_DIP , dp, view.resources.displayMetrics)
311+ .toInt()
177312 }
178313}
0 commit comments