77
88package com.facebook.react.devsupport
99
10- import android.animation.Animator
11- import android.animation.AnimatorListenerAdapter
12- import android.animation.AnimatorSet
13- import android.animation.ObjectAnimator
1410import android.graphics.Color
15- import android.graphics.drawable.GradientDrawable
16- import android.os.Build
17- import android.os.SystemClock
18- import android.util.TypedValue
19- import android.view.Gravity
20- import android.view.LayoutInflater
21- import android.view.View
22- import android.view.ViewGroup
23- import android.view.WindowManager
24- import android.view.animation.AccelerateInterpolator
25- import android.view.animation.DecelerateInterpolator
26- import android.widget.ImageView
27- import android.widget.PopupWindow
28- import android.widget.TextView
29- import androidx.core.view.ViewCompat
30- import androidx.core.view.WindowInsetsCompat
31- import com.facebook.common.logging.FLog
32- import com.facebook.react.R
3311import com.facebook.react.bridge.UiThreadUtil
34- import com.facebook.react.common.ReactConstants
3512import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
3613import java.util.Locale
3714
@@ -40,12 +17,9 @@ import java.util.Locale
4017 * the top of the screen. All methods are thread safe.
4118 */
4219public class DefaultDevLoadingViewImplementation (
43- private val reactInstanceDevHelper : ReactInstanceDevHelper ,
20+ reactInstanceDevHelper : ReactInstanceDevHelper ,
4421) : DevLoadingViewManager {
45- private var devLoadingView: TextView ? = null
46- private var devLoadingPopup: PopupWindow ? = null
47- private var hideAnimator: Animator ? = null
48- private var showTimeMs = 0L
22+ private val banner = DevBanner (reactInstanceDevHelper)
4923
5024 override fun showMessage (message : String ) {
5125 showMessage(message, color = null , backgroundColor = null , dismissButton = false )
@@ -61,7 +35,12 @@ public class DefaultDevLoadingViewImplementation(
6135 return
6236 }
6337 UiThreadUtil .runOnUiThread {
64- showInternal(message, color, backgroundColor, dismissButton ? : false )
38+ banner.show(
39+ message,
40+ color?.toInt() ? : Color .WHITE ,
41+ backgroundColor?.toInt() ? : Color .rgb(64 , 64 , 64 ), // Default grey
42+ dismissButton ? : false ,
43+ )
6544 }
6645 }
6746
@@ -75,239 +54,21 @@ public class DefaultDevLoadingViewImplementation(
7554 else if (done != null && total != null && total > 0 )
7655 String .format(Locale .getDefault(), " %.1f%%" , done.toFloat() / total * 100 )
7756 else " "
78- devLoadingView?.text =
79- " ${status ? : " Loading" }${percentage} \u2026 " // `...` character at the end
57+ banner.updateMessage(" ${status ? : " Loading" }${percentage} …" ) // `...` character at the end
8058 }
8159 }
8260
8361 public override fun hide () {
8462 if (isEnabled) {
85- UiThreadUtil .runOnUiThread { hideInternal () }
63+ UiThreadUtil .runOnUiThread { banner.hide () }
8664 }
8765 }
8866
89- private fun showInternal (
90- message : String ,
91- color : Double? ,
92- backgroundColor : Double? ,
93- dismissButton : Boolean ,
94- ) {
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)
105- return
106- }
107-
108- val currentActivity = reactInstanceDevHelper.currentActivity
109- if (currentActivity == null ) {
110- FLog .e(
111- ReactConstants .TAG ,
112- " Unable to display loading message because react " + " activity isn't available" ,
113- )
114- return
115- }
116-
117- try {
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)
122-
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
144-
145- pill.setOnClickListener { hideInternal() }
146- dismissButtonView.setOnClickListener { hideInternal() }
147-
148- val popup =
149- PopupWindow (
150- pill,
151- ViewGroup .LayoutParams .WRAP_CONTENT ,
152- ViewGroup .LayoutParams .WRAP_CONTENT ,
153- )
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
170- devLoadingPopup = popup
171- animateIn(pill)
172-
173- // TODO T164786028: Find out the root cause of the BadTokenException exception here
174- } catch (e: WindowManager .BadTokenException ) {
175- FLog .e(
176- ReactConstants .TAG ,
177- " Unable to display loading message because react activity isn't active, message: $message " ,
178- )
179- }
180- }
181-
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-
235- private fun hideInternal () {
236- val popup = devLoadingPopup ? : return
237- if (! popup.isShowing || hideAnimator != null ) {
238- return
239- }
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()
289- }
290-
29167 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-
29868 private var isEnabled = true
29969
30070 public fun setDevLoadingEnabled (enabled : Boolean ) {
30171 isEnabled = enabled
30272 }
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()
31273 }
31374}
0 commit comments