Skip to content

Commit 38ab882

Browse files
committed
Redesign DevLoadingView (Android)
1 parent 51d10d0 commit 38ab882

3 files changed

Lines changed: 221 additions & 83 deletions

File tree

‎packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/DefaultDevLoadingViewImplementation.kt‎

Lines changed: 194 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,27 @@
77

88
package 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
1114
import 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
1319
import android.view.Gravity
1420
import android.view.LayoutInflater
21+
import android.view.View
1522
import android.view.ViewGroup
1623
import android.view.WindowManager
24+
import android.view.animation.AccelerateInterpolator
25+
import android.view.animation.DecelerateInterpolator
26+
import android.widget.ImageView
1727
import android.widget.PopupWindow
1828
import android.widget.TextView
29+
import androidx.core.view.ViewCompat
30+
import androidx.core.view.WindowInsetsCompat
1931
import com.facebook.common.logging.FLog
2032
import com.facebook.react.R
2133
import com.facebook.react.bridge.UiThreadUtil
@@ -24,14 +36,16 @@ import com.facebook.react.devsupport.interfaces.DevLoadingViewManager
2436
import 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
*/
3042
public 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
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<vector xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="960"
6+
android:viewportHeight="960">
7+
<path
8+
android:fillColor="@android:color/white"
9+
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
10+
</vector>
Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,36 @@
11
<?xml version="1.0" encoding="utf-8"?>
22

33
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
4-
android:layout_width="match_parent"
4+
android:id="@+id/loading_pill"
5+
android:layout_width="wrap_content"
56
android:layout_height="wrap_content"
7+
android:minHeight="40dp"
68
android:orientation="horizontal"
7-
android:background="#404040"
9+
android:gravity="center_vertical"
10+
android:paddingStart="14dp"
11+
android:paddingEnd="14dp"
812
>
913

1014
<TextView
1115
android:id="@+id/loading_text"
12-
android:layout_width="0dp"
16+
android:layout_width="wrap_content"
1317
android:layout_height="wrap_content"
14-
android:layout_weight="1"
15-
android:layout_gravity="center_vertical"
1618
android:gravity="center"
1719
android:paddingTop="10dp"
1820
android:paddingBottom="10dp"
19-
android:paddingStart="8dp"
20-
android:paddingEnd="8dp"
21-
android:textSize="12sp"
22-
android:fontFamily="sans-serif"
21+
android:lineSpacingExtra="2dp"
22+
android:textSize="12.5sp"
23+
android:fontFamily="sans-serif-medium"
2324
/>
2425

25-
<Button
26+
<ImageView
2627
android:id="@+id/dismiss_button"
27-
android:layout_width="wrap_content"
28-
android:layout_height="wrap_content"
29-
android:layout_gravity="center_vertical"
30-
android:layout_marginEnd="10dp"
31-
android:minWidth="0dp"
32-
android:minHeight="0dp"
33-
android:text="@string/catalyst_loading_dismiss"
34-
android:textAllCaps="false"
35-
android:textSize="11sp"
36-
android:fontFamily="sans-serif"
37-
android:paddingStart="16dp"
38-
android:paddingEnd="16dp"
39-
android:paddingTop="3dp"
40-
android:paddingBottom="3dp"
28+
android:layout_width="22dp"
29+
android:layout_height="22dp"
30+
android:layout_marginStart="8dp"
31+
android:padding="5dp"
32+
android:src="@drawable/ic_dev_loading_dismiss"
33+
android:contentDescription="@string/catalyst_loading_dismiss"
4134
/>
4235

4336
</LinearLayout>

0 commit comments

Comments
 (0)