Skip to content

Commit 172935e

Browse files
kligarskikkafar
andauthored
fix(Android): drawing order for multiple disappearing screens (#2806)
## Description Fixes drawing order to account for multiple disappearing screens (e.g. formSheets). ## Changes - move `ScreensCoordinatorLayout`, `ScreensAnimation` to seperate package - move `ChildDrawingOrderStrategy` interface to seperate file - track disappearing transitioning children & disable `ChildDrawingOrderStrategy` when the transition is over - add new, more general `ReverseOrderStartingFrom` strategy to replace `SwapLastTwo` and use it in `ScreenStack`'s `onUpdate` ## Test code and steps to reproduce Currently, the transition from a `formSheet` to `push` is not working properly (two animation mechanisms don't work correctly together) so it is difficult to verify drawing order. To temporarily disable formSheet animation, you can `return null` at the beginning of `ScreenStackFragment`'s `onCreateAnimator` method. Then go to `TestFormSheet` example sheet, open the sheet and then click `Open second`. New screen will be drawn over disappearing screens during the transition. ## Checklist - [x] Included code example that can be used to test this change - [ ] Ensured that CI passes --------- Co-authored-by: Kacper Kafara <kacperkafara@gmail.com>
1 parent 9a9864f commit 172935e

6 files changed

Lines changed: 223 additions & 210 deletions

File tree

android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt

Lines changed: 33 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -9,76 +9,14 @@ import com.facebook.react.uimanager.UIManagerHelper
99
import com.swmansion.rnscreens.Screen.StackAnimation
1010
import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents
1111
import com.swmansion.rnscreens.events.StackFinishTransitioningEvent
12+
import com.swmansion.rnscreens.stack.views.ChildrenDrawingOrderStrategy
13+
import com.swmansion.rnscreens.stack.views.ReverseFromIndex
14+
import com.swmansion.rnscreens.stack.views.ReverseOrder
15+
import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
1216
import com.swmansion.rnscreens.utils.setTweenAnimations
13-
import java.util.Collections
1417
import kotlin.collections.ArrayList
1518
import kotlin.math.max
1619

17-
internal interface ChildDrawingOrderStrategy {
18-
/**
19-
* Mutates the list of draw operations **in-place**.
20-
*/
21-
fun apply(drawingOperations: MutableList<ScreenStack.DrawingOp>)
22-
23-
/**
24-
* Enables the given strategy. When enabled - the strategy **might** mutate the operations
25-
* list passed to `apply` method.
26-
*/
27-
fun enable()
28-
29-
/**
30-
* Disables the given strategy - even when `apply` is called it **must not** produce
31-
* any side effect (it must not manipulate the drawing operations list passed to `apply` method).
32-
*/
33-
fun disable()
34-
35-
fun isEnabled(): Boolean
36-
}
37-
38-
internal abstract class ChildDrawingOrderStrategyBase(
39-
var enabled: Boolean = false,
40-
) : ChildDrawingOrderStrategy {
41-
override fun enable() {
42-
enabled = true
43-
}
44-
45-
override fun disable() {
46-
enabled = false
47-
}
48-
49-
override fun isEnabled() = enabled
50-
}
51-
52-
internal class SwapLastTwo : ChildDrawingOrderStrategyBase() {
53-
override fun apply(drawingOperations: MutableList<ScreenStack.DrawingOp>) {
54-
if (!isEnabled()) {
55-
return
56-
}
57-
if (drawingOperations.size >= 2) {
58-
Collections.swap(drawingOperations, drawingOperations.lastIndex, drawingOperations.lastIndex - 1)
59-
}
60-
}
61-
}
62-
63-
internal class ReverseOrderInRange(
64-
val range: IntRange,
65-
) : ChildDrawingOrderStrategyBase() {
66-
override fun apply(drawingOperations: MutableList<ScreenStack.DrawingOp>) {
67-
if (!isEnabled()) {
68-
return
69-
}
70-
71-
var startRange = range.start
72-
var endRange = range.endInclusive
73-
74-
while (startRange < endRange) {
75-
Collections.swap(drawingOperations, startRange, endRange)
76-
startRange += 1
77-
endRange -= 1
78-
}
79-
}
80-
}
81-
8220
class ScreenStack(
8321
context: Context?,
8422
) : ScreenContainer(context) {
@@ -88,9 +26,9 @@ class ScreenStack(
8826
private var drawingOps: MutableList<DrawingOp> = ArrayList()
8927
private var topScreenWrapper: ScreenStackFragmentWrapper? = null
9028
private var removalTransitionStarted = false
91-
private var previousChildrenCount = 0
9229

93-
private var childDrawingOrderStrategy: ChildDrawingOrderStrategy? = null
30+
private var childrenDrawingOrderStrategy: ChildrenDrawingOrderStrategy? = null
31+
private var disappearingTransitioningChildren: MutableList<View> = ArrayList()
9432

9533
var goingForward = false
9634

@@ -122,14 +60,25 @@ class ScreenStack(
12260
}
12361

12462
override fun startViewTransition(view: View) {
63+
check(view is ScreensCoordinatorLayout) { "[RNScreens] Unexpected type of ScreenStack direct subview ${view.javaClass}" }
12564
super.startViewTransition(view)
126-
childDrawingOrderStrategy?.enable()
65+
if (view.fragment.isRemoving) {
66+
disappearingTransitioningChildren.add(view)
67+
}
68+
if (disappearingTransitioningChildren.isNotEmpty()) {
69+
childrenDrawingOrderStrategy?.enable()
70+
}
12771
removalTransitionStarted = true
12872
}
12973

13074
override fun endViewTransition(view: View) {
13175
super.endViewTransition(view)
132-
childDrawingOrderStrategy?.disable()
76+
77+
disappearingTransitioningChildren.remove(view)
78+
79+
if (disappearingTransitioningChildren.isEmpty()) {
80+
childrenDrawingOrderStrategy?.disable()
81+
}
13382
if (removalTransitionStarted) {
13483
removalTransitionStarted = false
13584
dispatchOnFinishTransitioning()
@@ -172,14 +121,17 @@ class ScreenStack(
172121
var visibleBottom: ScreenFragmentWrapper? = null
173122

174123
// reset, to not use previously set strategy by mistake
175-
childDrawingOrderStrategy = null
124+
childrenDrawingOrderStrategy = null
176125

177126
// Determine new first & last visible screens.
178127
val notDismissedWrappers =
179128
screenWrappers
180129
.asReversed()
181130
.asSequence()
182-
.filter { !dismissedWrappers.contains(it) && it.screen.activityState !== Screen.ActivityState.INACTIVE }
131+
.filter {
132+
!dismissedWrappers.contains(it) &&
133+
it.screen.activityState !== Screen.ActivityState.INACTIVE
134+
}
183135

184136
newTop = notDismissedWrappers.firstOrNull()
185137
visibleBottom =
@@ -226,16 +178,16 @@ class ScreenStack(
226178
needsDrawReordering(newTop, stackAnimation) &&
227179
visibleBottom == null
228180
) {
229-
// When using an open animation in which two screens overlap (eg. fade_from_bottom or
230-
// slide_from_bottom), we want to draw the previous screen under the new one,
231-
// which is apparently not the default option. Android always draws the disappearing view
181+
// When using an open animation in which screens overlap (eg. fade_from_bottom or
182+
// slide_from_bottom), we want to draw the previous screens under the new one,
183+
// which is apparently not the default option. Android always draws the disappearing views
232184
// on top of the appearing one. We then reverse the order of the views so the new screen
233-
// appears on top of the previous one. You can read more about in the comment
185+
// appears on top of the previous ones. You can read more about in the comment
234186
// for the code we use to change that behavior:
235187
// https://github.com/airbnb/native-navigation/blob/9cf50bf9b751b40778f473f3b19fcfe2c4d40599/lib/android/src/main/java/com/airbnb/android/react/navigation/ScreenCoordinatorLayout.java#L18
236188
// Note: This should not be set in case there is only a single screen in stack or animation `none` is used.
237189
// Atm needsDrawReordering implementation guards that assuming that first screen on stack uses `NONE` animation.
238-
childDrawingOrderStrategy = SwapLastTwo()
190+
childrenDrawingOrderStrategy = ReverseOrder()
239191
} else if (newTop != null &&
240192
newTopAlreadyInStack &&
241193
topScreenWrapper?.isTranslucent() == true &&
@@ -253,8 +205,8 @@ class ScreenStack(
253205
it.isTranslucent()
254206
}.count()
255207
if (dismissedTransparentScreenApproxCount > 1) {
256-
childDrawingOrderStrategy =
257-
ReverseOrderInRange(max(stack.lastIndex - dismissedTransparentScreenApproxCount + 1, 0)..stack.lastIndex)
208+
childrenDrawingOrderStrategy =
209+
ReverseFromIndex(max(stack.lastIndex - dismissedTransparentScreenApproxCount + 1, 0))
258210
}
259211
}
260212

@@ -351,13 +303,7 @@ class ScreenStack(
351303
override fun dispatchDraw(canvas: Canvas) {
352304
super.dispatchDraw(canvas)
353305

354-
// check the view removal is completed (by comparing the previous children count)
355-
if (drawingOps.size < previousChildrenCount) {
356-
childDrawingOrderStrategy = null
357-
}
358-
previousChildrenCount = drawingOps.size
359-
360-
childDrawingOrderStrategy?.apply(drawingOps)
306+
childrenDrawingOrderStrategy?.apply(drawingOps)
361307

362308
drawAndRelease()
363309
}
@@ -407,7 +353,7 @@ class ScreenStack(
407353
fragmentWrapper: ScreenFragmentWrapper,
408354
resolvedStackAnimation: StackAnimation?,
409355
): Boolean {
410-
val stackAnimation = if (resolvedStackAnimation != null) resolvedStackAnimation else fragmentWrapper.screen.stackAnimation
356+
val stackAnimation = resolvedStackAnimation ?: fragmentWrapper.screen.stackAnimation
411357
// On Android sdk 33 and above the animation is different and requires draw reordering.
412358
// For React Native 0.70 and lower versions, `Build.VERSION_CODES.TIRAMISU` is not defined yet.
413359
// Hence, we're comparing numerical version here.

android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt

Lines changed: 1 addition & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import android.animation.Animator
44
import android.animation.AnimatorSet
55
import android.animation.ValueAnimator
66
import android.annotation.SuppressLint
7-
import android.content.Context
87
import android.graphics.Color
98
import android.graphics.drawable.ColorDrawable
109
import android.os.Bundle
@@ -14,15 +13,11 @@ import android.view.MenuInflater
1413
import android.view.MenuItem
1514
import android.view.View
1615
import android.view.ViewGroup
17-
import android.view.WindowInsets
1816
import android.view.animation.Animation
19-
import android.view.animation.AnimationSet
20-
import android.view.animation.Transformation
2117
import android.widget.LinearLayout
2218
import androidx.appcompat.widget.Toolbar
2319
import androidx.coordinatorlayout.widget.CoordinatorLayout
2420
import com.facebook.react.uimanager.PixelUtil
25-
import com.facebook.react.uimanager.ReactPointerEventsView
2621
import com.facebook.react.uimanager.UIManagerHelper
2722
import com.google.android.material.appbar.AppBarLayout
2823
import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior
@@ -37,6 +32,7 @@ import com.swmansion.rnscreens.events.ScreenAnimationDelegate
3732
import com.swmansion.rnscreens.events.ScreenDismissedEvent
3833
import com.swmansion.rnscreens.events.ScreenEventEmitter
3934
import com.swmansion.rnscreens.ext.recycle
35+
import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout
4036
import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator
4137
import com.swmansion.rnscreens.utils.DeviceUtils
4238
import com.swmansion.rnscreens.utils.resolveBackgroundColor
@@ -471,122 +467,4 @@ class ScreenStackFragment :
471467
}
472468
return sheetDelegate!!
473469
}
474-
475-
private class ScreensCoordinatorLayout(
476-
context: Context,
477-
private val fragment: ScreenStackFragment,
478-
private val pointerEventsImpl: ReactPointerEventsView,
479-
// ) : CoordinatorLayout(context), ReactCompoundViewGroup, ReactHitSlopView {
480-
) : CoordinatorLayout(context),
481-
ReactPointerEventsView by pointerEventsImpl {
482-
constructor(context: Context, fragment: ScreenStackFragment) : this(
483-
context,
484-
fragment,
485-
PointerEventsBoxNoneImpl(),
486-
)
487-
488-
override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets = super.onApplyWindowInsets(insets)
489-
490-
private val animationListener: Animation.AnimationListener =
491-
object : Animation.AnimationListener {
492-
override fun onAnimationStart(animation: Animation) {
493-
fragment.onViewAnimationStart()
494-
}
495-
496-
override fun onAnimationEnd(animation: Animation) {
497-
fragment.onViewAnimationEnd()
498-
}
499-
500-
override fun onAnimationRepeat(animation: Animation) {}
501-
}
502-
503-
override fun startAnimation(animation: Animation) {
504-
// For some reason View##onAnimationEnd doesn't get called for
505-
// exit transitions so we explicitly attach animation listener.
506-
// We also have some animations that are an AnimationSet, so we don't wrap them
507-
// in another set since it causes some visual glitches when going forward.
508-
// We also set the listener only when going forward, since when going back,
509-
// there is already a listener for dismiss action added, which would be overridden
510-
// and also this is not necessary when going back since the lifecycle methods
511-
// are correctly dispatched then.
512-
// We also add fakeAnimation to the set of animations, which sends the progress of animation
513-
val fakeAnimation = ScreensAnimation(fragment).apply { duration = animation.duration }
514-
515-
if (animation is AnimationSet && !fragment.isRemoving) {
516-
animation
517-
.apply {
518-
addAnimation(fakeAnimation)
519-
setAnimationListener(animationListener)
520-
}.also {
521-
super.startAnimation(it)
522-
}
523-
} else {
524-
AnimationSet(true)
525-
.apply {
526-
addAnimation(animation)
527-
addAnimation(fakeAnimation)
528-
setAnimationListener(animationListener)
529-
}.also {
530-
super.startAnimation(it)
531-
}
532-
}
533-
}
534-
535-
/**
536-
* This method implements a workaround for RN's autoFocus functionality. Because of the way
537-
* autoFocus is implemented it dismisses soft keyboard in fragment transition
538-
* due to change of visibility of the view at the start of the transition. Here we override the
539-
* call to `clearFocus` when the visibility of view is `INVISIBLE` since `clearFocus` triggers the
540-
* hiding of the keyboard in `ReactEditText.java`.
541-
*/
542-
override fun clearFocus() {
543-
if (visibility != INVISIBLE) {
544-
super.clearFocus()
545-
}
546-
}
547-
548-
override fun onLayout(
549-
changed: Boolean,
550-
l: Int,
551-
t: Int,
552-
r: Int,
553-
b: Int,
554-
) {
555-
super.onLayout(changed, l, t, r, b)
556-
557-
if (fragment.screen.usesFormSheetPresentation()) {
558-
fragment.screen.onBottomSheetBehaviorDidLayout(changed)
559-
}
560-
}
561-
562-
// override fun reactTagForTouch(touchX: Float, touchY: Float): Int {
563-
// throw IllegalStateException("Screen wrapper should never be asked for the view tag")
564-
// }
565-
//
566-
// override fun interceptsTouchEvent(touchX: Float, touchY: Float): Boolean {
567-
// return false
568-
// }
569-
//
570-
// override fun getHitSlopRect(): Rect? {
571-
// val screen: Screen = fragment.screen
572-
// // left – The X coordinate of the left side of the rectangle
573-
// // top – The Y coordinate of the top of the rectangle i
574-
// // right – The X coordinate of the right side of the rectangle
575-
// // bottom – The Y coordinate of the bottom of the rectangle
576-
// return Rect(screen.x.toInt(), -screen.y.toInt(), screen.x.toInt() + screen.width, screen.y.toInt() + screen.height)
577-
// }
578-
}
579-
580-
private class ScreensAnimation(
581-
private val mFragment: ScreenFragment,
582-
) : Animation() {
583-
override fun applyTransformation(
584-
interpolatedTime: Float,
585-
t: Transformation,
586-
) {
587-
super.applyTransformation(interpolatedTime, t)
588-
// interpolated time should be the progress of the current transition
589-
mFragment.dispatchTransitionProgressEvent(interpolatedTime, !mFragment.isResumed)
590-
}
591-
}
592470
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.swmansion.rnscreens.stack.anim
2+
3+
import android.view.animation.Animation
4+
import android.view.animation.Transformation
5+
import com.swmansion.rnscreens.ScreenFragment
6+
7+
internal class ScreensAnimation(
8+
private val mFragment: ScreenFragment,
9+
) : Animation() {
10+
override fun applyTransformation(
11+
interpolatedTime: Float,
12+
t: Transformation,
13+
) {
14+
super.applyTransformation(interpolatedTime, t)
15+
// interpolated time should be the progress of the current transition
16+
mFragment.dispatchTransitionProgressEvent(interpolatedTime, !mFragment.isResumed)
17+
}
18+
}

0 commit comments

Comments
 (0)