diff --git a/android/src/main/java/com/swmansion/rnscreens/Screen.kt b/android/src/main/java/com/swmansion/rnscreens/Screen.kt index a04a6bef69..b13dd81d7e 100644 --- a/android/src/main/java/com/swmansion/rnscreens/Screen.kt +++ b/android/src/main/java/com/swmansion/rnscreens/Screen.kt @@ -5,6 +5,7 @@ import android.content.pm.ActivityInfo import android.graphics.Paint import android.os.Parcelable import android.util.SparseArray +import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.WindowManager @@ -17,6 +18,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.facebook.react.bridge.GuardedRunnable import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.PixelUtil +import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.UIManagerModule import com.facebook.react.uimanager.events.EventDispatcher @@ -24,13 +26,16 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel +import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents +import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation import com.swmansion.rnscreens.events.HeaderHeightChangeEvent import com.swmansion.rnscreens.events.SheetDetentChangedEvent +import com.swmansion.rnscreens.ext.parentAsViewGroup import java.lang.ref.WeakReference @SuppressLint("ViewConstructor") // Only we construct this view, it is never inflated. class Screen( - val reactContext: ReactContext, + val reactContext: ThemedReactContext, ) : FabricEnabledViewGroup(reactContext), ScreenContentWrapper.OnLayoutCallback { val fragment: Fragment? @@ -81,6 +86,13 @@ class Screen( var sheetClosesOnTouchOutside = true var sheetElevation: Float = 24F + /** + * When using form sheet presentation we want to delay enter transition **on Paper** in order + * to wait for initial layout from React, otherwise the animator-based animation will look + * glitchy. *This is not needed on Fabric*. + */ + var shouldTriggerPostponedTransitionAfterLayout = false + var footer: ScreenFooter? = null set(value) { if (value == null && field != null) { @@ -110,7 +122,7 @@ class Screen( * `fitToContents` for formSheets, as this is first entry point where we can acquire * height of our content. */ - override fun onLayoutCallback( + override fun onContentWrapperLayout( changed: Boolean, left: Int, top: Int, @@ -119,12 +131,23 @@ class Screen( ) { val height = bottom - top - if (sheetDetents.count() == 1 && sheetDetents.first() == SHEET_FIT_TO_CONTENTS) { + if (isSheetFitToContents()) { sheetBehavior?.let { if (it.maxHeight != height) { it.maxHeight = height } } + + if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // On old architecture we delay enter transition in order to wait for initial frame. + shouldTriggerPostponedTransitionAfterLayout = true + val parent = parentAsViewGroup() + if (parent != null && !parent.isInLayout) { + // There are reported cases (irreproducible) when Screen is not laid out after + // maxHeight is set on behaviour. + parent.requestLayout() + } + } } } @@ -162,6 +185,17 @@ class Screen( footer?.onParentLayout(changed, l, t, r, b, container!!.height) notifyHeaderHeightChange(t) + + if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + maybeTriggerPostponedTransition() + } + } + } + + private fun maybeTriggerPostponedTransition() { + if (shouldTriggerPostponedTransitionAfterLayout) { + shouldTriggerPostponedTransitionAfterLayout = false + fragment?.startPostponedEnterTransition() } } @@ -377,6 +411,28 @@ class Screen( } } + fun endRemovalTransition() { + if (!isBeingRemoved) { + return + } + isBeingRemoved = false + endTransitionRecursive(this) + } + + private fun endTransitionRecursive(parent: ViewGroup) { + parent.children.forEach { childView -> + parent.endViewTransition(childView) + + if (childView is ScreenStackHeaderConfig) { + endTransitionRecursive(childView.toolbar) + } + + if (childView is ViewGroup) { + endTransitionRecursive(childView) + } + } + } + private fun startTransitionRecursive(parent: ViewGroup?) { parent?.let { for (i in 0 until it.childCount) { @@ -407,6 +463,17 @@ class Screen( } } + // We do not want to perform any action, therefore do not need to override the associated method. + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean = + if (usesFormSheetPresentation()) { + // If we're a form sheet we want to consume the gestures to prevent + // DimmingView's callback from triggering when clicking on the sheet itself. + true + } else { + super.onTouchEvent(event) + } + private fun notifyHeaderHeightChange(headerHeight: Int) { val screenContext = context as ReactContext val surfaceId = UIManagerHelper.getSurfaceId(screenContext) diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt index 9ab8fb513b..d972f2aadf 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenContentWrapper.kt @@ -17,7 +17,7 @@ class ScreenContentWrapper( internal var delegate: OnLayoutCallback? = null interface OnLayoutCallback { - fun onLayoutCallback( + fun onContentWrapperLayout( changed: Boolean, left: Int, top: Int, @@ -33,6 +33,6 @@ class ScreenContentWrapper( right: Int, bottom: Int, ) { - delegate?.onLayoutCallback(changed, left, top, right, bottom) + delegate?.onContentWrapperLayout(changed, left, top, right, bottom) } } diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt index 5d34509ecf..255092cf37 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenFragment.kt @@ -15,7 +15,6 @@ import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.UIManagerHelper import com.facebook.react.uimanager.events.Event import com.facebook.react.uimanager.events.EventDispatcher -import com.swmansion.rnscreens.bottomsheet.DimmingFragment import com.swmansion.rnscreens.events.HeaderBackButtonClickedEvent import com.swmansion.rnscreens.events.ScreenAppearEvent import com.swmansion.rnscreens.events.ScreenDisappearEvent @@ -290,12 +289,7 @@ open class ScreenFragment : // since we subscribe to parent's animation start/end and dispatch events in child from there // check for `isTransitioning` should be enough since the child's animation should take only // 20ms due to always being `StackAnimation.NONE` when nested stack being pushed - val parent = - if (parentFragment is DimmingFragment) { - parentFragment?.parentFragment - } else { - parentFragment - } + val parent = parentFragment if (parent == null || (parent is ScreenFragment && !parent.isTransitioning)) { // onViewAnimationStart/End is triggered from View#onAnimationStart/End method of the fragment's root // view. We override an appropriate method of the StackFragment's diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt index 1e00fd2f7e..6d167b518c 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStack.kt @@ -7,11 +7,10 @@ import android.view.View import com.facebook.react.bridge.ReactContext import com.facebook.react.uimanager.UIManagerHelper import com.swmansion.rnscreens.Screen.StackAnimation -import com.swmansion.rnscreens.bottomsheet.DimmingFragment +import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents import com.swmansion.rnscreens.events.StackFinishTransitioningEvent import java.util.Collections import kotlin.collections.ArrayList -import kotlin.collections.HashSet class ScreenStack( context: Context?, @@ -50,7 +49,7 @@ class ScreenStack( override fun adapt(screen: Screen): ScreenStackFragmentWrapper = when (screen.stackPresentation) { - Screen.StackPresentation.FORM_SHEET -> DimmingFragment(ScreenStackFragment(screen)) + Screen.StackPresentation.FORM_SHEET -> ScreenStackFragment(screen) else -> ScreenStackFragment(screen) } @@ -242,7 +241,6 @@ class ScreenStack( } } } - // animation logic end goingForward = shouldUseOpenAnimation @@ -302,6 +300,12 @@ class ScreenStack( } } } else if (newTop != null && !newTop.fragment.isAdded) { + if (!BuildConfig.IS_NEW_ARCHITECTURE_ENABLED && newTop.screen.isSheetFitToContents()) { + // On old architecture the content wrapper might not have received its frame yet, + // which is required to determine height of the sheet after animation. Therefore + // we delay the transition and trigger it after views receive the layout. + newTop.fragment.postponeEnterTransition() + } it.add(id, newTop.fragment) } topScreenWrapper = newTop as? ScreenStackFragmentWrapper diff --git a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt index dfcac58fa9..c6b36130f8 100644 --- a/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt +++ b/android/src/main/java/com/swmansion/rnscreens/ScreenStackFragment.kt @@ -1,5 +1,8 @@ package com.swmansion.rnscreens +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ValueAnimator import android.annotation.SuppressLint import android.content.Context import android.graphics.Color @@ -16,17 +19,18 @@ import android.view.WindowInsets import android.view.WindowManager import android.view.animation.Animation import android.view.animation.AnimationSet -import android.view.animation.AnimationUtils import android.view.animation.Transformation import android.view.inputmethod.InputMethodManager import android.widget.LinearLayout import androidx.annotation.RequiresApi import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.animation.addListener import androidx.core.view.WindowInsetsCompat import com.facebook.react.uimanager.PixelUtil import com.facebook.react.uimanager.PointerEvents 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 import com.google.android.material.bottomsheet.BottomSheetBehavior @@ -34,13 +38,18 @@ import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCa import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.ShapeAppearanceModel +import com.swmansion.rnscreens.bottomsheet.DimmingViewManager +import com.swmansion.rnscreens.bottomsheet.SheetDelegate import com.swmansion.rnscreens.bottomsheet.SheetUtils import com.swmansion.rnscreens.bottomsheet.isSheetFitToContents import com.swmansion.rnscreens.bottomsheet.useSingleDetent import com.swmansion.rnscreens.bottomsheet.useThreeDetents import com.swmansion.rnscreens.bottomsheet.useTwoDetents import com.swmansion.rnscreens.bottomsheet.usesFormSheetPresentation +import com.swmansion.rnscreens.events.ScreenDismissedEvent +import com.swmansion.rnscreens.events.ScreenEventDelegate import com.swmansion.rnscreens.ext.recycle +import com.swmansion.rnscreens.transition.ExternalBoundaryValuesEvaluator import com.swmansion.rnscreens.utils.DeviceUtils sealed class KeyboardState @@ -76,6 +85,13 @@ class ScreenStackFragment : return container } + private val dimmingDelegate = + lazy(LazyThreadSafetyMode.NONE) { + DimmingViewManager(screen.reactContext, screen) + } + + private var sheetDelegate: SheetDelegate? = null + @SuppressLint("ValidFragment") constructor(screenView: Screen) : super(screenView) @@ -131,7 +147,12 @@ class ScreenStackFragment : override fun onViewAnimationEnd() { super.onViewAnimationEnd() + + // Rely on guards inside the callee to detect whether this was indeed appear transition. notifyViewAppearTransitionEnd() + + // Rely on guards inside the callee to detect whether this was indeed removal transition. + screen.endRemovalTransition() } private fun notifyViewAppearTransitionEnd() { @@ -176,7 +197,7 @@ class ScreenStackFragment : } if (newState == BottomSheetBehavior.STATE_HIDDEN) { - nativeDismissalObserver?.onNativeDismiss(this@ScreenStackFragment) + dismissSelf() } } @@ -186,18 +207,17 @@ class ScreenStackFragment : ) = Unit } - override fun onCreateAnimation( - transit: Int, - enter: Boolean, - nextAnim: Int, - ): Animation? { - if (screen.stackPresentation != Screen.StackPresentation.FORM_SHEET) { - return null - } - return if (enter) { - AnimationUtils.loadAnimation(context, R.anim.rns_slide_in_from_bottom) - } else { - AnimationUtils.loadAnimation(context, R.anim.rns_slide_out_to_bottom) + /** + * Currently this method dispatches event to JS where state is recomputed and fragment + * gets removed in the result of incoming state update. + */ + internal fun dismissSelf() { + if (!this.isRemoving || !this.isDetached) { + val reactContext = screen.reactContext + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + UIManagerHelper + .getEventDispatcherForReactTag(reactContext, screen.id) + ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) } } @@ -205,6 +225,10 @@ class ScreenStackFragment : screen.onSheetCornerRadiusChange() } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -262,6 +286,90 @@ class ScreenStackFragment : return coordinatorLayout } + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + if (!screen.usesFormSheetPresentation()) { + return + } + + sheetDelegate = SheetDelegate(screen) + + assert(view == coordinatorLayout) + dimmingDelegate.value.onViewHierarchyCreated(screen, coordinatorLayout) + dimmingDelegate.value.onBehaviourAttached(screen, screen.sheetBehavior!!) + + val container = screen.container!! + coordinatorLayout.measure( + View.MeasureSpec.makeMeasureSpec(container.width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(container.height, View.MeasureSpec.EXACTLY), + ) + coordinatorLayout.layout(0, 0, container.width, container.height) + } + + override fun onCreateAnimation( + transit: Int, + enter: Boolean, + nextAnim: Int, + ): Animation? { + // Ensure onCreateAnimator is called + return null + } + + override fun onCreateAnimator( + transit: Int, + enter: Boolean, + nextAnim: Int, + ): Animator? { + if (!screen.usesFormSheetPresentation()) { + // Use animation defined while defining transaction in screen stack + return null + } + + val animatorSet = AnimatorSet() + + if (enter) { + val alphaAnimator = + ValueAnimator.ofFloat(0f, dimmingDelegate.value.maxAlpha).apply { + addUpdateListener { anim -> + val animatedValue = anim.animatedValue as? Float + animatedValue?.let { dimmingDelegate.value.dimmingView.alpha = it } + } + } + val startValueCallback = { initialStartValue: Number? -> screen.height.toFloat() } + val evaluator = ExternalBoundaryValuesEvaluator(startValueCallback, { 0f }) + val slideAnimator = + ValueAnimator.ofObject(evaluator, screen.height.toFloat(), 0f).apply { + addUpdateListener { anim -> + val animatedValue = anim.animatedValue as? Float + animatedValue?.let { screen.translationY = it } + } + } + animatorSet.play(alphaAnimator).with(slideAnimator) + } else { + val alphaAnimator = + ValueAnimator.ofFloat(dimmingDelegate.value.dimmingView.alpha, 0f).apply { + addUpdateListener { anim -> + val animatedValue = anim.animatedValue as? Float + animatedValue?.let { dimmingDelegate.value.dimmingView.alpha = it } + } + } + val slideAnimator = + ValueAnimator.ofFloat(0f, (coordinatorLayout.bottom - screen.top).toFloat()).apply { + addUpdateListener { anim -> + val animatedValue = anim.animatedValue as? Float + animatedValue?.let { screen.translationY = it } + } + } + animatorSet.play(alphaAnimator).with(slideAnimator) + } + animatorSet.addListener(ScreenEventDelegate(this)) + return animatorSet + } + /** * This method might return slightly different values depending on code path, * but during testing I've found this effect negligible. For practical purposes @@ -348,7 +456,10 @@ class ScreenStackFragment : behavior.apply { val height = if (screen.isSheetFitToContents()) { - screen.contentWrapper.get()?.height + screen.contentWrapper + .get() + ?.height + .takeIf { screen.contentWrapper.get()?.isLaidOut == true } } else { (screen.sheetDetents.first() * containerHeight).toInt() } @@ -456,10 +567,12 @@ class ScreenStackFragment : } } + private fun createBottomSheetBehaviour(): BottomSheetBehavior = BottomSheetBehavior() + // In general it would be great to create BottomSheetBehaviour only via this method as it runs some // side effects. - internal fun createAndConfigureBottomSheetBehaviour(): BottomSheetBehavior = - configureBottomSheetBehaviour(BottomSheetBehavior()) + private fun createAndConfigureBottomSheetBehaviour(): BottomSheetBehavior = + configureBottomSheetBehaviour(createBottomSheetBehaviour()) private fun attachShapeToScreen(screen: Screen) { val cornerSize = PixelUtil.toPixelFromDIP(screen.sheetCornerRadius) diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetBehaviorExt.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetBehaviorExt.kt index 97c299654f..5e1d1b5683 100644 --- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetBehaviorExt.kt +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/BottomSheetBehaviorExt.kt @@ -12,7 +12,9 @@ internal fun BottomSheetBehavior.useSingleDetent( if (forceExpandedState) { this.state = BottomSheetBehavior.STATE_EXPANDED } - height?.let { maxHeight = height } + height?.let { + maxHeight = height + } return this } diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt deleted file mode 100644 index 6ed054f8a4..0000000000 --- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingFragment.kt +++ /dev/null @@ -1,493 +0,0 @@ -package com.swmansion.rnscreens.bottomsheet - -import android.animation.ValueAnimator -import android.app.Activity -import android.graphics.Color -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.animation.Animation -import android.view.animation.AnimationUtils -import androidx.appcompat.widget.Toolbar -import androidx.core.graphics.Insets -import androidx.core.view.OnApplyWindowInsetsListener -import androidx.core.view.WindowInsetsCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.commit -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.LifecycleOwner -import com.facebook.react.bridge.ReactContext -import com.facebook.react.uimanager.UIManagerHelper -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback -import com.swmansion.rnscreens.InsetsObserverProxy -import com.swmansion.rnscreens.KeyboardDidHide -import com.swmansion.rnscreens.KeyboardNotVisible -import com.swmansion.rnscreens.KeyboardState -import com.swmansion.rnscreens.KeyboardVisible -import com.swmansion.rnscreens.NativeDismissalObserver -import com.swmansion.rnscreens.R -import com.swmansion.rnscreens.Screen -import com.swmansion.rnscreens.ScreenContainer -import com.swmansion.rnscreens.ScreenFragment -import com.swmansion.rnscreens.ScreenFragmentWrapper -import com.swmansion.rnscreens.ScreenStack -import com.swmansion.rnscreens.ScreenStackFragment -import com.swmansion.rnscreens.ScreenStackFragmentWrapper -import com.swmansion.rnscreens.events.ScreenDismissedEvent - -/** - * This fragment aims to provide dimming view functionality behind the nested fragment. - * Useful when nested fragment is transparent / uses some kind of non-fullscreen presentation, - * such as `formSheet`. - */ -class DimmingFragment( - val nestedFragment: ScreenFragmentWrapper, -) : Fragment(), - LifecycleEventObserver, - ScreenStackFragmentWrapper, - Animation.AnimationListener, - OnApplyWindowInsetsListener, - NativeDismissalObserver { - private lateinit var dimmingView: DimmingView - private lateinit var containerView: GestureTransparentViewGroup - - private val maxAlpha: Float = 0.15F - - private var isKeyboardVisible: Boolean = false - private var keyboardState: KeyboardState = KeyboardNotVisible - - private var dimmingViewCallback: BottomSheetCallback? = null - - private val container: ScreenStack? - get() = screen.container as? ScreenStack - - private val insetsProxy = InsetsObserverProxy - - init { - assert( - nestedFragment.fragment is ScreenStackFragment, - ) { "[RNScreens] Dimming fragment is intended for use only with ScreenStackFragment" } - val fragment = nestedFragment.fragment as ScreenStackFragment - - // We register for our child lifecycle as we want to know when it starts, because bottom sheet - // behavior is attached only then & we want to attach our own callbacks to it. - fragment.lifecycle.addObserver(this) - fragment.nativeDismissalObserver = this - } - - /** - * This bottom sheet callback is responsible for animating alpha of the dimming view. - */ - private class AnimateDimmingViewCallback( - val screen: Screen, - val viewToAnimate: View, - val maxAlpha: Float, - ) : BottomSheetCallback() { - // largest *slide offset* that is yet undimmed - private var largestUndimmedOffset: Float = - computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) - - // first *slide offset* that should be fully dimmed - private var firstDimmedOffset: Float = - computeOffsetFromDetentIndex( - (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( - 0, - screen.sheetDetents.count() - 1, - ), - ) - - // interval that we interpolate the alpha value over - private var intervalLength = firstDimmedOffset - largestUndimmedOffset - private val animator = - ValueAnimator.ofFloat(0F, maxAlpha).apply { - duration = 1 // Driven manually - addUpdateListener { - viewToAnimate.alpha = it.animatedValue as Float - } - } - - override fun onStateChanged( - bottomSheet: View, - newState: Int, - ) { - if (newState == BottomSheetBehavior.STATE_DRAGGING || newState == BottomSheetBehavior.STATE_SETTLING) { - largestUndimmedOffset = - computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) - firstDimmedOffset = - computeOffsetFromDetentIndex( - (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( - 0, - screen.sheetDetents.count() - 1, - ), - ) - assert(firstDimmedOffset >= largestUndimmedOffset) { - "[RNScreens] Invariant violation: firstDimmedOffset ($firstDimmedOffset) < largestDimmedOffset ($largestUndimmedOffset)" - } - intervalLength = firstDimmedOffset - largestUndimmedOffset - } - } - - override fun onSlide( - bottomSheet: View, - slideOffset: Float, - ) { - if (largestUndimmedOffset < slideOffset && slideOffset < firstDimmedOffset) { - val fraction = (slideOffset - largestUndimmedOffset) / intervalLength - animator.setCurrentFraction(fraction) - } - } - - /** - * This method does compute slide offset (see [BottomSheetCallback.onSlide] docs) for detent - * at given index in the detents array. - */ - private fun computeOffsetFromDetentIndex(index: Int): Float = - when (screen.sheetDetents.size) { - 1 -> // Only 1 detent present in detents array - when (index) { - -1 -> -1F // hidden - 0 -> 1F // fully expanded - else -> -1F // unexpected, default - } - - 2 -> - when (index) { - -1 -> -1F // hidden - 0 -> 0F // collapsed - 1 -> 1F // expanded - else -> -1F - } - - 3 -> - when (index) { - -1 -> -1F // hidden - 0 -> 0F // collapsed - 1 -> screen.sheetBehavior!!.halfExpandedRatio // half - 2 -> 1F // expanded - else -> -1F - } - - else -> -1F - } - } - - override fun onCreateAnimation( - transit: Int, - enter: Boolean, - nextAnim: Int, - ): Animation? = - // We want dimming view to have always fade animation in current usages. - AnimationUtils.loadAnimation( - context, - if (enter) R.anim.rns_fade_in else R.anim.rns_fade_out, - ) - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View { - initViewHierarchy() - return containerView - } - - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { - if (screen.sheetInitialDetentIndex <= screen.sheetLargestUndimmedDetentIndex) { - dimmingView.alpha = 0.0F - } else { - dimmingView.alpha = maxAlpha - } - } - - override fun onStart() { - // This is the earliest we can access child fragment manager & present another fragment - super.onStart() - insetsProxy.registerOnView(requireDecorView()) - presentNestedFragment() - } - - override fun onResume() { - insetsProxy.addOnApplyWindowInsetsListener(this) - super.onResume() - } - - override fun onPause() { - super.onPause() - insetsProxy.removeOnApplyWindowInsetsListener(this) - } - - override fun onStateChanged( - source: LifecycleOwner, - event: Lifecycle.Event, - ) { - when (event) { - Lifecycle.Event.ON_START -> { - nestedFragment.screen.sheetBehavior?.let { - dimmingViewCallback = - AnimateDimmingViewCallback(nestedFragment.screen, dimmingView, maxAlpha) - it.addBottomSheetCallback(dimmingViewCallback!!) - } - } - - else -> {} - } - } - - private fun presentNestedFragment() { - childFragmentManager.commit(allowStateLoss = true) { - setReorderingAllowed(true) - add(requireView().id, nestedFragment.fragment, null) - } - } - - private fun cleanRegisteredCallbacks() { - dimmingViewCallback?.let { - nestedFragment.screen.sheetBehavior?.removeBottomSheetCallback(it) - } - dimmingView.setOnClickListener(null) - nestedFragment.fragment.lifecycle.removeObserver(this) - insetsProxy.removeOnApplyWindowInsetsListener(this) - } - - private fun dismissSelf(emitDismissedEvent: Boolean = false) { - if (!this.isRemoving) { - if (emitDismissedEvent) { - val reactContext = nestedFragment.screen.reactContext - val surfaceId = UIManagerHelper.getSurfaceId(reactContext) - UIManagerHelper - .getEventDispatcherForReactTag(reactContext, screen.id) - ?.dispatchEvent(ScreenDismissedEvent(surfaceId, screen.id)) - } - cleanRegisteredCallbacks() - dismissFromContainer() - } - } - - private fun initViewHierarchy() { - initContainerView() - initDimmingView() - containerView.addView(dimmingView) - } - - private fun initContainerView() { - containerView = - GestureTransparentViewGroup(requireContext()).apply { - // These do not guarantee fullscreen width & height, TODO: find a way to guarantee that - layoutParams = - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - setBackgroundColor(Color.TRANSPARENT) - // This is purely native view, React does not know of it, thus there should be no conflict with ids. - id = View.generateViewId() - } - } - - private fun initDimmingView() { - dimmingView = - DimmingView(requireContext(), maxAlpha).apply { - // These do not guarantee fullscreen width & height, TODO: find a way to guarantee that - layoutParams = - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - setOnClickListener { - if (screen.sheetClosesOnTouchOutside) { - dismissSelf(true) - } - } - } - } - - private fun requireDecorView(): View = - checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" } - .window.decorView - - // TODO: Move these methods related to toolbar to separate interface - override fun removeToolbar() = Unit - - override fun setToolbar(toolbar: Toolbar) = Unit - - override fun setToolbarShadowHidden(hidden: Boolean) = Unit - - override fun setToolbarTranslucent(translucent: Boolean) = Unit - - // Dimming view should never be bottom-most fragment - override fun canNavigateBack(): Boolean = true - - override fun dismissFromContainer() { - container?.dismiss(this) - } - - override var screen: Screen - get() = nestedFragment.screen - set(value) { - nestedFragment.screen = value - } - - override val childScreenContainers: List = nestedFragment.childScreenContainers - - override fun addChildScreenContainer(container: ScreenContainer) { - nestedFragment.addChildScreenContainer(container) - } - - override fun removeChildScreenContainer(container: ScreenContainer) { - nestedFragment.removeChildScreenContainer(container) - } - - override fun onContainerUpdate() { - nestedFragment.onContainerUpdate() - } - - override fun onViewAnimationStart() { - nestedFragment.onViewAnimationStart() - } - - override fun onViewAnimationEnd() { - nestedFragment.onViewAnimationEnd() - } - - override fun tryGetActivity(): Activity? = activity - - override fun tryGetContext(): ReactContext? = context as? ReactContext? - - override val fragment: Fragment - get() = this - - override fun canDispatchLifecycleEvent(event: ScreenFragment.ScreenLifecycleEvent): Boolean { - TODO("Not yet implemented") - } - - override fun updateLastEventDispatched(event: ScreenFragment.ScreenLifecycleEvent) { - TODO("Not yet implemented") - } - - override fun dispatchLifecycleEvent( - event: ScreenFragment.ScreenLifecycleEvent, - fragmentWrapper: ScreenFragmentWrapper, - ) { - TODO("Not yet implemented") - } - - override fun dispatchLifecycleEventInChildContainers(event: ScreenFragment.ScreenLifecycleEvent) { - TODO("Not yet implemented") - } - - override fun dispatchHeaderBackButtonClickedEvent() { - TODO("Not yet implemented") - } - - override fun dispatchTransitionProgressEvent( - alpha: Float, - closing: Boolean, - ) { - TODO("Not yet implemented") - } - - override fun onAnimationStart(animation: Animation?) = Unit - - override fun onAnimationEnd(animation: Animation?) { - dismissFromContainer() - } - - override fun onAnimationRepeat(animation: Animation?) = Unit - - companion object { - const val TAG = "DimmingFragment" - } - - // This is View.OnApplyWindowInsetsListener method, not view's own! - override fun onApplyWindowInsets( - v: View, - insets: WindowInsetsCompat, - ): WindowInsetsCompat { - val isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) - val imeInset = insets.getInsets(WindowInsetsCompat.Type.ime()) - - if (isImeVisible) { - isKeyboardVisible = true - keyboardState = KeyboardVisible(imeInset.bottom) - screen.sheetBehavior?.let { - (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( - it, - KeyboardVisible(imeInset.bottom), - ) - } - - if (this.isRemoving) { - return insets - } - - val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - return WindowInsetsCompat - .Builder(insets) - .setInsets( - WindowInsetsCompat.Type.navigationBars(), - Insets.of( - prevInsets.left, - prevInsets.top, - prevInsets.right, - 0, - ), - ).build() - } else { - if (this.isRemoving) { - val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - return WindowInsetsCompat - .Builder(insets) - .setInsets( - WindowInsetsCompat.Type.navigationBars(), - Insets.of( - prevInsets.left, - prevInsets.top, - prevInsets.right, - 0, - ), - ).build() - } - - screen.sheetBehavior?.let { - if (isKeyboardVisible) { - (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( - it, - KeyboardDidHide, - ) - } else if (keyboardState != KeyboardNotVisible) { - (nestedFragment as ScreenStackFragment).configureBottomSheetBehaviour( - it, - KeyboardNotVisible, - ) - } else { - } - } - - keyboardState = KeyboardNotVisible - isKeyboardVisible = false - - val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) - return WindowInsetsCompat - .Builder(insets) - .setInsets( - WindowInsetsCompat.Type.navigationBars(), - Insets.of( - prevInsets.left, - prevInsets.top, - prevInsets.right, - 0, - ), - ).build() - } - } - - override fun onNativeDismiss(dismissed: ScreenStackFragmentWrapper) { - dismissSelf(emitDismissedEvent = true) - } -} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt index b1350dbc84..7ef4861ba1 100644 --- a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingView.kt @@ -11,7 +11,7 @@ import com.facebook.react.uimanager.ReactPointerEventsView import com.swmansion.rnscreens.ext.equalWithRespectToEps /** - * Serves as dimming view that can be used as background for some view that not fully fills + * Serves as dimming view that can be used as background for some view that does not fully fill * the viewport. * * This dimming view has one more additional feature: it blocks gestures if its alpha > 0. @@ -40,6 +40,8 @@ class DimmingView( b: Int, ) = Unit + // We do not want to have any action defined here. We just want listeners notified that the click happened. + @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent?): Boolean { if (blockGestures) { callOnClick() diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingViewManager.kt new file mode 100644 index 0000000000..744e58efd8 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/DimmingViewManager.kt @@ -0,0 +1,165 @@ +package com.swmansion.rnscreens.bottomsheet + +import android.animation.ValueAnimator +import android.view.View +import android.view.ViewGroup +import com.facebook.react.uimanager.ThemedReactContext +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.swmansion.rnscreens.Screen +import com.swmansion.rnscreens.ScreenStackFragment + +/** + * Provides bulk of necessary logic for the dimming view accompanying the formSheet. + */ +class DimmingViewManager( + val reactContext: ThemedReactContext, + screen: Screen, +) { + internal val dimmingView: DimmingView = createDimmingView(screen) + internal val maxAlpha: Float = 0.3f + private var dimmingViewCallback: BottomSheetCallback? = null + + /** + * Should be called when hosting fragment has its view hierarchy created. + */ + fun onViewHierarchyCreated( + screen: Screen, + root: ViewGroup, + ) { + root.addView(dimmingView, 0) + if (screen.sheetInitialDetentIndex <= screen.sheetLargestUndimmedDetentIndex) { + dimmingView.alpha = 0.0f + } else { + dimmingView.alpha = maxAlpha + } + } + + /** + * Should be called after screen of hosting fragment has its behaviour attached. + */ + fun onBehaviourAttached( + screen: Screen, + behavior: BottomSheetBehavior, + ) { + behavior.addBottomSheetCallback(requireBottomSheetCallback(screen)) + } + + /** + * This bottom sheet callback is responsible for animating alpha of the dimming view. + */ + private class AnimateDimmingViewCallback( + val screen: Screen, + val viewToAnimate: View, + val maxAlpha: Float, + ) : BottomSheetCallback() { + // largest *slide offset* that is yet undimmed + private var largestUndimmedOffset: Float = + computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) + + // first *slide offset* that should be fully dimmed + private var firstDimmedOffset: Float = + computeOffsetFromDetentIndex( + (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( + 0, + screen.sheetDetents.count() - 1, + ), + ) + + // interval that we interpolate the alpha value over + private var intervalLength = firstDimmedOffset - largestUndimmedOffset + private val animator = + ValueAnimator.ofFloat(0F, maxAlpha).apply { + duration = 1 // Driven manually + addUpdateListener { + viewToAnimate.alpha = it.animatedValue as Float + } + } + + override fun onStateChanged( + bottomSheet: View, + newState: Int, + ) { + if (newState == BottomSheetBehavior.STATE_DRAGGING || newState == BottomSheetBehavior.STATE_SETTLING) { + largestUndimmedOffset = + computeOffsetFromDetentIndex(screen.sheetLargestUndimmedDetentIndex) + firstDimmedOffset = + computeOffsetFromDetentIndex( + (screen.sheetLargestUndimmedDetentIndex + 1).coerceIn( + 0, + screen.sheetDetents.count() - 1, + ), + ) + assert(firstDimmedOffset >= largestUndimmedOffset) { + "[RNScreens] Invariant violation: firstDimmedOffset ($firstDimmedOffset) < largestDimmedOffset ($largestUndimmedOffset)" + } + intervalLength = firstDimmedOffset - largestUndimmedOffset + } + } + + override fun onSlide( + bottomSheet: View, + slideOffset: Float, + ) { + if (largestUndimmedOffset < slideOffset && slideOffset < firstDimmedOffset) { + val fraction = (slideOffset - largestUndimmedOffset) / intervalLength + animator.setCurrentFraction(fraction) + } + } + + /** + * This method does compute slide offset (see [BottomSheetCallback.onSlide] docs) for detent + * at given index in the detents array. + */ + private fun computeOffsetFromDetentIndex(index: Int): Float = + when (screen.sheetDetents.size) { + 1 -> // Only 1 detent present in detents array + when (index) { + -1 -> -1F // hidden + 0 -> 1F // fully expanded + else -> -1F // unexpected, default + } + + 2 -> + when (index) { + -1 -> -1F // hidden + 0 -> 0F // collapsed + 1 -> 1F // expanded + else -> -1F + } + + 3 -> + when (index) { + -1 -> -1F // hidden + 0 -> 0F // collapsed + 1 -> screen.sheetBehavior!!.halfExpandedRatio // half + 2 -> 1F // expanded + else -> -1F + } + + else -> -1F + } + } + + private fun createDimmingView(screen: Screen): DimmingView = + DimmingView(reactContext, maxAlpha).apply { + // These do not guarantee fullscreen width & height, TODO: find a way to guarantee that + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setOnClickListener { + if (screen.sheetClosesOnTouchOutside) { + (screen.fragment as ScreenStackFragment).dismissSelf() + } + } + } + + private fun requireBottomSheetCallback(screen: Screen): BottomSheetCallback { + if (dimmingViewCallback == null) { + dimmingViewCallback = AnimateDimmingViewCallback(screen, dimmingView, maxAlpha) + } + return dimmingViewCallback!! + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt new file mode 100644 index 0000000000..0cb7b95a7b --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/bottomsheet/SheetDelegate.kt @@ -0,0 +1,119 @@ +package com.swmansion.rnscreens.bottomsheet + +import android.view.View +import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.swmansion.rnscreens.InsetsObserverProxy +import com.swmansion.rnscreens.KeyboardDidHide +import com.swmansion.rnscreens.KeyboardNotVisible +import com.swmansion.rnscreens.KeyboardState +import com.swmansion.rnscreens.KeyboardVisible +import com.swmansion.rnscreens.Screen +import com.swmansion.rnscreens.ScreenStackFragment + +class SheetDelegate( + val screen: Screen, +) : LifecycleEventObserver, + OnApplyWindowInsetsListener { + private var isKeyboardVisible: Boolean = false + private var keyboardState: KeyboardState = KeyboardNotVisible + + private val sheetBehavior: BottomSheetBehavior? + get() = screen.sheetBehavior + + private val stackFragment: ScreenStackFragment + get() = screen.fragment as ScreenStackFragment + + private fun requireDecorView(): View = + checkNotNull(screen.reactContext.currentActivity) { "[RNScreens] Attempt to access activity on detached context" } + .window.decorView + + init { + assert(screen.fragment is ScreenStackFragment) { "[RNScreens] Sheets are supported only in native stack" } + screen.fragment!!.lifecycle.addObserver(this) + } + + // LifecycleEventObserver + override fun onStateChanged( + source: LifecycleOwner, + event: Lifecycle.Event, + ) { + when (event) { + Lifecycle.Event.ON_START -> handleHostFragmentOnStart() + Lifecycle.Event.ON_RESUME -> handleHostFragmentOnResume() + Lifecycle.Event.ON_PAUSE -> handleHostFragmentOnPause() + else -> Unit + } + } + + private fun handleHostFragmentOnStart() { + InsetsObserverProxy.registerOnView(requireDecorView()) + } + + private fun handleHostFragmentOnResume() { + InsetsObserverProxy.addOnApplyWindowInsetsListener(this) + } + + private fun handleHostFragmentOnPause() { + InsetsObserverProxy.removeOnApplyWindowInsetsListener(this) + } + + // This is listener function, not the view's. + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + val isImeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) + val imeInset = insets.getInsets(WindowInsetsCompat.Type.ime()) + + if (isImeVisible) { + isKeyboardVisible = true + keyboardState = KeyboardVisible(imeInset.bottom) + sheetBehavior?.let { + stackFragment.configureBottomSheetBehaviour(it, keyboardState) + } + + val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return WindowInsetsCompat + .Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of( + prevInsets.left, + prevInsets.top, + prevInsets.right, + 0, + ), + ).build() + } else { + sheetBehavior?.let { + if (isKeyboardVisible) { + stackFragment.configureBottomSheetBehaviour(it, KeyboardDidHide) + } else if (keyboardState != KeyboardNotVisible) { + stackFragment.configureBottomSheetBehaviour(it, KeyboardNotVisible) + } else { + } + } + + keyboardState = KeyboardNotVisible + isKeyboardVisible = false + } + + val prevInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return WindowInsetsCompat + .Builder(insets) + .setInsets( + WindowInsetsCompat.Type.navigationBars(), + Insets.of(prevInsets.left, prevInsets.top, prevInsets.right, 0), + ).build() + } + + companion object { + const val TAG = "SheetDelegate" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/events/ScreenEventDelegate.kt b/android/src/main/java/com/swmansion/rnscreens/events/ScreenEventDelegate.kt new file mode 100644 index 0000000000..af79b6483f --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/events/ScreenEventDelegate.kt @@ -0,0 +1,48 @@ +package com.swmansion.rnscreens.events + +import android.animation.Animator +import com.swmansion.rnscreens.ScreenFragmentWrapper + +class ScreenEventDelegate( + private val wrapper: ScreenFragmentWrapper, +) : Animator.AnimatorListener { + private var currentState: LifecycleState = LifecycleState.INITIALIZED + + private fun progressState() { + currentState = + when (currentState) { + LifecycleState.INITIALIZED -> LifecycleState.START_DISPATCHED + LifecycleState.START_DISPATCHED -> LifecycleState.END_DISPATCHED + LifecycleState.END_DISPATCHED -> LifecycleState.END_DISPATCHED + } + } + + override fun onAnimationStart(animation: Animator) { + if (currentState === LifecycleState.INITIALIZED) { + progressState() + wrapper.onViewAnimationStart() + } + } + + override fun onAnimationEnd(animation: Animator) { + if (currentState === LifecycleState.START_DISPATCHED) { + progressState() + animation.removeListener(this) + wrapper.onViewAnimationEnd() + } + } + + override fun onAnimationCancel(animation: Animator) = Unit + + override fun onAnimationRepeat(animation: Animator) = Unit + + private enum class LifecycleState { + INITIALIZED, + START_DISPATCHED, + END_DISPATCHED, + } + + companion object { + const val TAG = "ScreenEventDelegate" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/transition/ExternalBoundaryValuesEvaluator.kt b/android/src/main/java/com/swmansion/rnscreens/transition/ExternalBoundaryValuesEvaluator.kt new file mode 100644 index 0000000000..f15f7463da --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/transition/ExternalBoundaryValuesEvaluator.kt @@ -0,0 +1,38 @@ +package com.swmansion.rnscreens.transition + +import android.animation.FloatEvaluator + +typealias BoundaryValueProviderFn = (Number?) -> Float? + +/** + * Float type evaluator that uses boundary values provided by callbacks passed as arguments and does + * not use boundary values used during value animator construction. This allows to defer computation + * of animator boundary values to the moment when animation starts. + */ +class ExternalBoundaryValuesEvaluator(val startValueProvider: BoundaryValueProviderFn, val endValueProvider: BoundaryValueProviderFn) : FloatEvaluator() { + var startValueCache: Number? = null + var endValueCache: Number? = null + + private fun getStartValue(startValue: Number?): Number? { + if (startValueCache == null) { + startValueCache = startValueProvider(startValue) + } + return startValueCache + } + + private fun getEndValue(endValue: Number?): Number? { + if (endValueCache == null) { + endValueCache = endValueProvider(endValue) + } + return endValueCache + } + + override fun evaluate(fraction: Float, startValue: Number?, endValue: Number?): Float? { + val realStartValue = getStartValue(startValue) + val realEndValue = getEndValue(endValue) + if (realStartValue == null || realEndValue == null) { + return null + } + return super.evaluate(fraction, realStartValue, realEndValue) + } +} diff --git a/apps/src/tests/TestAndroidTransitions.tsx b/apps/src/tests/TestAndroidTransitions.tsx index 0d98e720e6..898f8c1b74 100644 --- a/apps/src/tests/TestAndroidTransitions.tsx +++ b/apps/src/tests/TestAndroidTransitions.tsx @@ -58,6 +58,7 @@ function FormSheet({ navigation }: RouteProps<'FormSheet'> | RouteProps<'Modal'> + ); } @@ -69,7 +70,7 @@ function Modal({ navigation }: RouteProps<'Modal'>): React.JSX.Element {