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.util.TypedValue
1317import android.view.Gravity
1418import android.view.LayoutInflater
19+ import android.view.View
1520import android.view.ViewGroup
1621import android.view.WindowManager
22+ import android.view.animation.AccelerateInterpolator
23+ import android.view.animation.DecelerateInterpolator
24+ import android.widget.ImageView
1725import android.widget.PopupWindow
1826import android.widget.TextView
27+ import androidx.core.view.ViewCompat
28+ import androidx.core.view.WindowInsetsCompat
1929import com.facebook.common.logging.FLog
2030import com.facebook.react.R
2131import com.facebook.react.bridge.UiThreadUtil
@@ -24,14 +34,15 @@ import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
2434import java.util.Locale
2535
2636/* *
27- * Default implementation of Dev Loading View Manager to display loading messages on top of the
28- * screen. All methods are thread safe.
37+ * Default implementation of Dev Loading View Manager to display loading messages in a capsule at
38+ * the top of the screen. All methods are thread safe.
2939 */
3040public class DefaultDevLoadingViewImplementation (
3141 private val reactInstanceDevHelper : ReactInstanceDevHelper ,
3242) : DevLoadingViewManager {
3343 private var devLoadingView: TextView ? = null
3444 private var devLoadingPopup: PopupWindow ? = null
45+ private var hideAnimator: Animator ? = null
3546
3647 override fun showMessage (message : String ) {
3748 showMessage(message, color = null , backgroundColor = null , dismissButton = false )
@@ -78,10 +89,18 @@ public class DefaultDevLoadingViewImplementation(
7889 backgroundColor : Double? ,
7990 dismissButton : Boolean ,
8091 ) {
81- if (devLoadingPopup?.isShowing == true ) {
82- // already showing
92+ // A new message replaces one that is still animating out.
93+ hideAnimator?.cancel()
94+
95+ val textColor = color?.toInt() ? : Color .WHITE
96+ val bgColor = backgroundColor?.toInt() ? : Color .rgb(64 , 64 , 64 ) // Default grey
97+
98+ val existingPopup = devLoadingPopup
99+ if (existingPopup?.isShowing == true ) {
100+ applyContent(existingPopup.contentView, message, textColor, bgColor, dismissButton)
83101 return
84102 }
103+
85104 val currentActivity = reactInstanceDevHelper.currentActivity
86105 if (currentActivity == null ) {
87106 FLog .e(
@@ -91,64 +110,52 @@ public class DefaultDevLoadingViewImplementation(
91110 return
92111 }
93112
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.
97113 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)
130-
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
136-
137- dismissButtonView.setOnClickListener { hideInternal() }
138- }
139-
140- // Allow tapping anywhere on the banner to dismiss
141- rootView.setOnClickListener { hideInternal() }
114+ val decorView = currentActivity.window.decorView
115+ val inflater = LayoutInflater .from(currentActivity)
116+ val pill = inflater.inflate(R .layout.dev_loading_view, null ) as ViewGroup
117+ applyContent(pill, message, textColor, bgColor, dismissButton)
118+
119+ // Sits 12dp below a top inset such as the status bar, or 16dp from the top edge without one,
120+ // centered and kept clear of the side insets with a 16dp margin.
121+ val insets =
122+ ViewCompat .getRootWindowInsets(decorView)
123+ ?.getInsets(
124+ WindowInsetsCompat .Type .systemBars() or WindowInsetsCompat .Type .displayCutout()
125+ )
126+ val topInset = insets?.top ? : 0
127+ val sideInset = maxOf(insets?.left ? : 0 , insets?.right ? : 0 ) + dpToPx(pill, 16f )
128+ val textView = pill.findViewById<TextView >(R .id.loading_text)
129+ val dismissButtonView = pill.findViewById<View >(R .id.dismiss_button)
130+ val dismissButtonWidth =
131+ if (dismissButton) {
132+ (dismissButtonView.layoutParams as ViewGroup .MarginLayoutParams ).let {
133+ it.width + it.marginStart
134+ }
135+ } else {
136+ 0
137+ }
138+ textView.maxWidth =
139+ decorView.width - 2 * sideInset - pill.paddingStart - pill.paddingEnd - dismissButtonWidth
140+
141+ pill.setOnClickListener { hideInternal() }
142+ dismissButtonView.setOnClickListener { hideInternal() }
142143
143144 val popup =
144145 PopupWindow (
145- rootView ,
146- ViewGroup .LayoutParams .MATCH_PARENT ,
146+ pill ,
147+ ViewGroup .LayoutParams .WRAP_CONTENT ,
147148 ViewGroup .LayoutParams .WRAP_CONTENT ,
148149 )
149- popup.showAtLocation(currentActivity.window.decorView, Gravity .NO_GRAVITY , 0 , topOffset)
150- devLoadingView = textView // Store the TextView for updateProgress()
150+ popup.showAtLocation(
151+ decorView,
152+ Gravity .TOP or Gravity .CENTER_HORIZONTAL ,
153+ 0 ,
154+ if (topInset > 0 ) topInset + dpToPx(pill, 12f ) else dpToPx(pill, 16f ),
155+ )
156+ devLoadingView = textView
151157 devLoadingPopup = popup
158+ animateIn(pill)
152159
153160 // TODO T164786028: Find out the root cause of the BadTokenException exception here
154161 } catch (e: WindowManager .BadTokenException ) {
@@ -159,20 +166,113 @@ public class DefaultDevLoadingViewImplementation(
159166 }
160167 }
161168
169+ private fun applyContent (
170+ pill : View ,
171+ message : String ,
172+ textColor : Int ,
173+ bgColor : Int ,
174+ dismissButton : Boolean ,
175+ ) {
176+ pill.background =
177+ GradientDrawable ().apply {
178+ setColor(bgColor)
179+ // Larger than any banner height, so the corners always form a capsule.
180+ cornerRadius = dpToPx(pill, 1000f ).toFloat()
181+ }
182+ val textView = pill.findViewById<TextView >(R .id.loading_text)
183+ textView.text = message
184+ textView.setTextColor(textColor)
185+
186+ val dismissButtonView = pill.findViewById<ImageView >(R .id.dismiss_button)
187+ dismissButtonView.visibility = if (dismissButton) View .VISIBLE else View .GONE
188+ // The round button sits closer to the capsule's end than the text would.
189+ pill.setPaddingRelative(
190+ pill.paddingStart,
191+ pill.paddingTop,
192+ dpToPx(pill, if (dismissButton) 9f else 14f ),
193+ pill.paddingBottom,
194+ )
195+ dismissButtonView.setColorFilter(textColor)
196+ dismissButtonView.background =
197+ GradientDrawable ().apply {
198+ shape = GradientDrawable .OVAL
199+ setColor(
200+ Color .argb(51 , Color .red(textColor), Color .green(textColor), Color .blue(textColor))
201+ )
202+ }
203+ }
204+
205+ private fun animateIn (pill : View ) {
206+ pill.alpha = 0f
207+ pill.scaleX = HIDDEN_SCALE
208+ pill.scaleY = HIDDEN_SCALE
209+ pill.post {
210+ setHiddenPivot(pill)
211+ pill
212+ .animate()
213+ .alpha(1f )
214+ .scaleX(1f )
215+ .scaleY(1f )
216+ .setDuration(ANIMATION_DURATION_MS )
217+ .setInterpolator(DecelerateInterpolator ())
218+ .start()
219+ }
220+ }
221+
162222 private fun hideInternal () {
163223 val popup = devLoadingPopup ? : return
164- if (popup.isShowing == true ) {
165- popup.dismiss()
166- devLoadingPopup = null
167- devLoadingView = null
224+ if (! popup.isShowing || hideAnimator != null ) {
225+ return
168226 }
227+ val pill = popup.contentView
228+ setHiddenPivot(pill)
229+ val animator =
230+ AnimatorSet ().apply {
231+ playTogether(
232+ ObjectAnimator .ofFloat(pill, View .ALPHA , 0f ),
233+ ObjectAnimator .ofFloat(pill, View .SCALE_X , HIDDEN_SCALE ),
234+ ObjectAnimator .ofFloat(pill, View .SCALE_Y , HIDDEN_SCALE ),
235+ )
236+ duration = ANIMATION_DURATION_MS
237+ interpolator = AccelerateInterpolator ()
238+ addListener(
239+ object : AnimatorListenerAdapter () {
240+ override fun onAnimationEnd (animation : Animator ) {
241+ hideAnimator = null
242+ if (popup.isShowing) {
243+ popup.dismiss()
244+ }
245+ if (devLoadingPopup == = popup) {
246+ devLoadingPopup = null
247+ devLoadingView = null
248+ }
249+ }
250+ }
251+ )
252+ }
253+ hideAnimator = animator
254+ animator.start()
169255 }
170256
171257 public companion object {
258+ private const val ANIMATION_DURATION_MS = 200L
259+ private const val HIDDEN_SCALE = 0.85f
260+ // The hidden state scales about a point this fraction of the banner's height above its center.
261+ private const val HIDDEN_ANCHOR_OFFSET = 0.05f
262+
172263 private var isEnabled = true
173264
174265 public fun setDevLoadingEnabled (enabled : Boolean ) {
175266 isEnabled = enabled
176267 }
268+
269+ private fun setHiddenPivot (pill : View ) {
270+ pill.pivotX = pill.width / 2f
271+ pill.pivotY = pill.height * (0.5f - HIDDEN_ANCHOR_OFFSET )
272+ }
273+
274+ private fun dpToPx (view : View , dp : Float ): Int =
275+ TypedValue .applyDimension(TypedValue .COMPLEX_UNIT_DIP , dp, view.resources.displayMetrics)
276+ .toInt()
177277 }
178278}
0 commit comments