diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt index fc5c113ec9..2494ef0e07 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt @@ -9,76 +9,14 @@ import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.rnscreens.Screen.StackAnimation import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents import com.swmansion.rnscreens.events.StackFinishTransitioningEvent +import com.swmansion.rnscreens.stack.views.ChildrenDrawingOrderStrategy +import com.swmansion.rnscreens.stack.views.ReverseFromIndex +import com.swmansion.rnscreens.stack.views.ReverseOrder +import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout import com.swmansion.rnscreens.utils.setTweenAnimations -import java.util.Collections import kotlin.collections.ArrayList import kotlin.math.max -internal interface ChildDrawingOrderStrategy { - /** - * Mutates the list of draw operations **in-place**. - */ - fun apply(drawingOperations: MutableList) - - /** - * Enables the given strategy. When enabled - the strategy **might** mutate the operations - * list passed to `apply` method. - */ - fun enable() - - /** - * Disables the given strategy - even when `apply` is called it **must not** produce - * any side effect (it must not manipulate the drawing operations list passed to `apply` method). - */ - fun disable() - - fun isEnabled(): Boolean -} - -internal abstract class ChildDrawingOrderStrategyBase( - var enabled: Boolean = false, -) : ChildDrawingOrderStrategy { - override fun enable() { - enabled = true - } - - override fun disable() { - enabled = false - } - - override fun isEnabled() = enabled -} - -internal class SwapLastTwo : ChildDrawingOrderStrategyBase() { - override fun apply(drawingOperations: MutableList) { - if (!isEnabled()) { - return - } - if (drawingOperations.size >= 2) { - Collections.swap(drawingOperations, drawingOperations.lastIndex, drawingOperations.lastIndex - 1) - } - } -} - -internal class ReverseOrderInRange( - val range: IntRange, -) : ChildDrawingOrderStrategyBase() { - override fun apply(drawingOperations: MutableList) { - if (!isEnabled()) { - return - } - - var startRange = range.start - var endRange = range.endInclusive - - while (startRange < endRange) { - Collections.swap(drawingOperations, startRange, endRange) - startRange += 1 - endRange -= 1 - } - } -} - class ScreenStack( context: Context?, ) : ScreenContainer(context) { @@ -88,9 +26,9 @@ class ScreenStack( private var drawingOps: MutableList = ArrayList() private var topScreenWrapper: ScreenStackFragmentWrapper? = null private var removalTransitionStarted = false - private var previousChildrenCount = 0 - private var childDrawingOrderStrategy: ChildDrawingOrderStrategy? = null + private var childrenDrawingOrderStrategy: ChildrenDrawingOrderStrategy? = null + private var disappearingTransitioningChildren: MutableList = ArrayList() var goingForward = false @@ -122,14 +60,25 @@ class ScreenStack( } override fun startViewTransition(view: View) { + check(view is ScreensCoordinatorLayout) { "[RNScreens] Unexpected type of ScreenStack direct subview ${view.javaClass}" } super.startViewTransition(view) - childDrawingOrderStrategy?.enable() + if (view.fragment.isRemoving) { + disappearingTransitioningChildren.add(view) + } + if (disappearingTransitioningChildren.isNotEmpty()) { + childrenDrawingOrderStrategy?.enable() + } removalTransitionStarted = true } override fun endViewTransition(view: View) { super.endViewTransition(view) - childDrawingOrderStrategy?.disable() + + disappearingTransitioningChildren.remove(view) + + if (disappearingTransitioningChildren.isEmpty()) { + childrenDrawingOrderStrategy?.disable() + } if (removalTransitionStarted) { removalTransitionStarted = false dispatchOnFinishTransitioning() @@ -172,14 +121,17 @@ class ScreenStack( var visibleBottom: ScreenFragmentWrapper? = null // reset, to not use previously set strategy by mistake - childDrawingOrderStrategy = null + childrenDrawingOrderStrategy = null // Determine new first & last visible screens. val notDismissedWrappers = screenWrappers .asReversed() .asSequence() - .filter { !dismissedWrappers.contains(it) && it.screen.activityState !== Screen.ActivityState.INACTIVE } + .filter { + !dismissedWrappers.contains(it) && + it.screen.activityState !== Screen.ActivityState.INACTIVE + } newTop = notDismissedWrappers.firstOrNull() visibleBottom = @@ -226,16 +178,16 @@ class ScreenStack( needsDrawReordering(newTop, stackAnimation) && visibleBottom == null ) { - // When using an open animation in which two screens overlap (eg. fade_from_bottom or - // slide_from_bottom), we want to draw the previous screen under the new one, - // which is apparently not the default option. Android always draws the disappearing view + // When using an open animation in which screens overlap (eg. fade_from_bottom or + // slide_from_bottom), we want to draw the previous screens under the new one, + // which is apparently not the default option. Android always draws the disappearing views // on top of the appearing one. We then reverse the order of the views so the new screen - // appears on top of the previous one. You can read more about in the comment + // appears on top of the previous ones. You can read more about in the comment // for the code we use to change that behavior: // https://github.com/airbnb/native-navigation/blob/9cf50bf9b751b40778f473f3b19fcfe2c4d40599/lib/android/src/main/java/com/airbnb/android/react/navigation/ScreenCoordinatorLayout.java#L18 // Note: This should not be set in case there is only a single screen in stack or animation `none` is used. // Atm needsDrawReordering implementation guards that assuming that first screen on stack uses `NONE` animation. - childDrawingOrderStrategy = SwapLastTwo() + childrenDrawingOrderStrategy = ReverseOrder() } else if (newTop != null && newTopAlreadyInStack && topScreenWrapper?.isTranslucent() == true && @@ -253,8 +205,8 @@ class ScreenStack( it.isTranslucent() }.count() if (dismissedTransparentScreenApproxCount > 1) { - childDrawingOrderStrategy = - ReverseOrderInRange(max(stack.lastIndex - dismissedTransparentScreenApproxCount + 1, 0)..stack.lastIndex) + childrenDrawingOrderStrategy = + ReverseFromIndex(max(stack.lastIndex - dismissedTransparentScreenApproxCount + 1, 0)) } } @@ -351,13 +303,7 @@ class ScreenStack( override fun dispatchDraw(canvas: Canvas) { super.dispatchDraw(canvas) - // check the view removal is completed (by comparing the previous children count) - if (drawingOps.size < previousChildrenCount) { - childDrawingOrderStrategy = null - } - previousChildrenCount = drawingOps.size - - childDrawingOrderStrategy?.apply(drawingOps) + childrenDrawingOrderStrategy?.apply(drawingOps) drawAndRelease() } @@ -407,7 +353,7 @@ class ScreenStack( fragmentWrapper: ScreenFragmentWrapper, resolvedStackAnimation: StackAnimation?, ): Boolean { - val stackAnimation = if (resolvedStackAnimation != null) resolvedStackAnimation else fragmentWrapper.screen.stackAnimation + val stackAnimation = resolvedStackAnimation ?: fragmentWrapper.screen.stackAnimation // On Android sdk 33 and above the animation is different and requires draw reordering. // For React Native 0.70 and lower versions, `Build.VERSION_CODES.TIRAMISU` is not defined yet. // Hence, we're comparing numerical version here. diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt index baccd632fc..acca1c6e38 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt @@ -4,7 +4,6 @@ import android.animation.Animator import android.animation.AnimatorSet import android.animation.ValueAnimator import android.annotation.SuppressLint -import android.content.Context import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.os.Bundle @@ -14,15 +13,11 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.WindowInsets import android.view.animation.Animation -import android.view.animation.AnimationSet -import android.view.animation.Transformation import android.widget.LinearLayout import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import com.facebook.react.uimanager.PixelUtil -import com.facebook.react.uimanager.ReactPointerEventsView import com.facebook.react.uimanager.UIManagerHelper import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.ScrollingViewBehavior @@ -37,6 +32,7 @@ import com.swmansion.rnscreens.events.ScreenAnimationDelegate import com.swmansion.rnscreens.events.ScreenDismissedEvent import com.swmansion.rnscreens.events.ScreenEventEmitter import com.swmansion.rnscreens.ext.recycle +import com.swmansion.rnscreens.stack.views.ScreensCoordinatorLayout import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator import com.swmansion.rnscreens.utils.DeviceUtils import com.swmansion.rnscreens.utils.resolveBackgroundColor @@ -471,122 +467,4 @@ class ScreenStackFragment : } return sheetDelegate!! } - - private class ScreensCoordinatorLayout( - context: Context, - private val fragment: ScreenStackFragment, - private val pointerEventsImpl: ReactPointerEventsView, -// ) : CoordinatorLayout(context), ReactCompoundViewGroup, ReactHitSlopView { - ) : CoordinatorLayout(context), - ReactPointerEventsView by pointerEventsImpl { - constructor(context: Context, fragment: ScreenStackFragment) : this( - context, - fragment, - PointerEventsBoxNoneImpl(), - ) - - override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets = super.onApplyWindowInsets(insets) - - private val animationListener: Animation.AnimationListener = - object : Animation.AnimationListener { - override fun onAnimationStart(animation: Animation) { - fragment.onViewAnimationStart() - } - - override fun onAnimationEnd(animation: Animation) { - fragment.onViewAnimationEnd() - } - - override fun onAnimationRepeat(animation: Animation) {} - } - - override fun startAnimation(animation: Animation) { - // For some reason View##onAnimationEnd doesn't get called for - // exit transitions so we explicitly attach animation listener. - // We also have some animations that are an AnimationSet, so we don't wrap them - // in another set since it causes some visual glitches when going forward. - // We also set the listener only when going forward, since when going back, - // there is already a listener for dismiss action added, which would be overridden - // and also this is not necessary when going back since the lifecycle methods - // are correctly dispatched then. - // We also add fakeAnimation to the set of animations, which sends the progress of animation - val fakeAnimation = ScreensAnimation(fragment).apply { duration = animation.duration } - - if (animation is AnimationSet && !fragment.isRemoving) { - animation - .apply { - addAnimation(fakeAnimation) - setAnimationListener(animationListener) - }.also { - super.startAnimation(it) - } - } else { - AnimationSet(true) - .apply { - addAnimation(animation) - addAnimation(fakeAnimation) - setAnimationListener(animationListener) - }.also { - super.startAnimation(it) - } - } - } - - /** - * This method implements a workaround for RN's autoFocus functionality. Because of the way - * autoFocus is implemented it dismisses soft keyboard in fragment transition - * due to change of visibility of the view at the start of the transition. Here we override the - * call to `clearFocus` when the visibility of view is `INVISIBLE` since `clearFocus` triggers the - * hiding of the keyboard in `ReactEditText.java`. - */ - override fun clearFocus() { - if (visibility != INVISIBLE) { - super.clearFocus() - } - } - - override fun onLayout( - changed: Boolean, - l: Int, - t: Int, - r: Int, - b: Int, - ) { - super.onLayout(changed, l, t, r, b) - - if (fragment.screen.usesFormSheetPresentation()) { - fragment.screen.onBottomSheetBehaviorDidLayout(changed) - } - } - -// override fun reactTagForTouch(touchX: Float, touchY: Float): Int { -// throw IllegalStateException("Screen wrapper should never be asked for the view tag") -// } -// -// override fun interceptsTouchEvent(touchX: Float, touchY: Float): Boolean { -// return false -// } -// -// override fun getHitSlopRect(): Rect? { -// val screen: Screen = fragment.screen -// // left – The X coordinate of the left side of the rectangle -// // top – The Y coordinate of the top of the rectangle i -// // right – The X coordinate of the right side of the rectangle -// // bottom – The Y coordinate of the bottom of the rectangle -// return Rect(screen.x.toInt(), -screen.y.toInt(), screen.x.toInt() + screen.width, screen.y.toInt() + screen.height) -// } - } - - private class ScreensAnimation( - private val mFragment: ScreenFragment, - ) : Animation() { - override fun applyTransformation( - interpolatedTime: Float, - t: Transformation, - ) { - super.applyTransformation(interpolatedTime, t) - // interpolated time should be the progress of the current transition - mFragment.dispatchTransitionProgressEvent(interpolatedTime, !mFragment.isResumed) - } - } } diff --git a/android/src/main/java/com/swmansion/rnscreens/stack/anim/ScreensAnimation.kt b/android/src/main/java/com/swmansion/rnscreens/stack/anim/ScreensAnimation.kt new file mode 100644 index 0000000000..7194ae8a97 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/stack/anim/ScreensAnimation.kt @@ -0,0 +1,18 @@ +package com.swmansion.rnscreens.stack.anim + +import android.view.animation.Animation +import android.view.animation.Transformation +import com.swmansion.rnscreens.ScreenFragment + +internal class ScreensAnimation( + private val mFragment: ScreenFragment, +) : Animation() { + override fun applyTransformation( + interpolatedTime: Float, + t: Transformation, + ) { + super.applyTransformation(interpolatedTime, t) + // interpolated time should be the progress of the current transition + mFragment.dispatchTransitionProgressEvent(interpolatedTime, !mFragment.isResumed) + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildDrawingOrderStrategyImpl.kt b/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildDrawingOrderStrategyImpl.kt new file mode 100644 index 0000000000..50fa5d611b --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildDrawingOrderStrategyImpl.kt @@ -0,0 +1,48 @@ +package com.swmansion.rnscreens.stack.views + +import com.swmansion.rnscreens.ScreenStack +import java.util.Collections + +internal abstract class ChildrenDrawingOrderStrategyBase( + var enabled: Boolean = false, +) : ChildrenDrawingOrderStrategy { + override fun enable() { + enabled = true + } + + override fun disable() { + enabled = false + } + + override fun isEnabled() = enabled +} + + +internal class ReverseFromIndex( + val startIndex: Int, +) : ChildrenDrawingOrderStrategyBase() { + override fun apply(drawingOperations: MutableList) { + if (!isEnabled()) { + return + } + + var currentLeftIndex = startIndex + var currentRightIndex = drawingOperations.lastIndex + + while (currentLeftIndex < currentRightIndex) { + Collections.swap(drawingOperations, currentLeftIndex, currentRightIndex) + currentLeftIndex += 1 + currentRightIndex -= 1 + } + } +} + +internal class ReverseOrder : ChildrenDrawingOrderStrategyBase() { + override fun apply(drawingOperations: MutableList) { + if (!isEnabled()) { + return + } + + drawingOperations.reverse() + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildrenDrawingOrderStrategy.kt b/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildrenDrawingOrderStrategy.kt new file mode 100644 index 0000000000..740210c7d5 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/stack/views/ChildrenDrawingOrderStrategy.kt @@ -0,0 +1,24 @@ +package com.swmansion.rnscreens.stack.views + +import com.swmansion.rnscreens.ScreenStack + +internal interface ChildrenDrawingOrderStrategy { + /** + * Mutates the list of draw operations **in-place**. + */ + fun apply(drawingOperations: MutableList) + + /** + * Enables the given strategy. When enabled - the strategy **might** mutate the operations + * list passed to `apply` method. + */ + fun enable() + + /** + * Disables the given strategy - even when `apply` is called it **must not** produce + * any side effect (it must not manipulate the drawing operations list passed to `apply` method). + */ + fun disable() + + fun isEnabled(): Boolean +} diff --git a/android/src/main/java/com/swmansion/rnscreens/stack/views/ScreensCoordinatorLayout.kt b/android/src/main/java/com/swmansion/rnscreens/stack/views/ScreensCoordinatorLayout.kt new file mode 100644 index 0000000000..ff1409d9b2 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/stack/views/ScreensCoordinatorLayout.kt @@ -0,0 +1,99 @@ +package com.swmansion.rnscreens.stack.views + +import android.content.Context +import android.view.WindowInsets +import android.view.animation.Animation +import android.view.animation.AnimationSet +import androidx.coordinatorlayout.widget.CoordinatorLayout +import com.facebook.react.uimanager.ReactPointerEventsView +import com.swmansion.rnscreens.PointerEventsBoxNoneImpl +import com.swmansion.rnscreens.ScreenStackFragment +import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation +import com.swmansion.rnscreens.stack.anim.ScreensAnimation + +internal class ScreensCoordinatorLayout( + context: Context, + internal val fragment: ScreenStackFragment, + private val pointerEventsImpl: ReactPointerEventsView, +) : CoordinatorLayout(context), + ReactPointerEventsView by pointerEventsImpl { + constructor(context: Context, fragment: ScreenStackFragment) : this( + context, + fragment, + PointerEventsBoxNoneImpl(), + ) + + override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets = super.onApplyWindowInsets(insets) + + private val animationListener: Animation.AnimationListener = + object : Animation.AnimationListener { + override fun onAnimationStart(animation: Animation) { + fragment.onViewAnimationStart() + } + + override fun onAnimationEnd(animation: Animation) { + fragment.onViewAnimationEnd() + } + + override fun onAnimationRepeat(animation: Animation) {} + } + + override fun startAnimation(animation: Animation) { + // For some reason View##onAnimationEnd doesn't get called for + // exit transitions so we explicitly attach animation listener. + // We also have some animations that are an AnimationSet, so we don't wrap them + // in another set since it causes some visual glitches when going forward. + // We also set the listener only when going forward, since when going back, + // there is already a listener for dismiss action added, which would be overridden + // and also this is not necessary when going back since the lifecycle methods + // are correctly dispatched then. + // We also add fakeAnimation to the set of animations, which sends the progress of animation + val fakeAnimation = ScreensAnimation(fragment).apply { duration = animation.duration } + + if (animation is AnimationSet && !fragment.isRemoving) { + animation + .apply { + addAnimation(fakeAnimation) + setAnimationListener(animationListener) + }.also { + super.startAnimation(it) + } + } else { + AnimationSet(true) + .apply { + addAnimation(animation) + addAnimation(fakeAnimation) + setAnimationListener(animationListener) + }.also { + super.startAnimation(it) + } + } + } + + /** + * This method implements a workaround for RN's autoFocus functionality. Because of the way + * autoFocus is implemented it dismisses soft keyboard in fragment transition + * due to change of visibility of the view at the start of the transition. Here we override the + * call to `clearFocus` when the visibility of view is `INVISIBLE` since `clearFocus` triggers the + * hiding of the keyboard in `ReactEditText.java`. + */ + override fun clearFocus() { + if (visibility != INVISIBLE) { + super.clearFocus() + } + } + + override fun onLayout( + changed: Boolean, + l: Int, + t: Int, + r: Int, + b: Int, + ) { + super.onLayout(changed, l, t, r, b) + + if (fragment.screen.usesFormSheetPresentation()) { + fragment.screen.onBottomSheetBehaviorDidLayout(changed) + } + } +}