diff --git a/Example/android/app/libs/overscroll-release-v1.1-20160904.jar b/Example/android/app/libs/overscroll-release-v1.1-20160904.jar new file mode 100644 index 000000000..4e1d2d348 Binary files /dev/null and b/Example/android/app/libs/overscroll-release-v1.1-20160904.jar differ diff --git a/Example/android/app/src/main/java/tonlabs/uikit/MainApplication.java b/Example/android/app/src/main/java/tonlabs/uikit/MainApplication.java index 145a75f91..adfa5fad4 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/MainApplication.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/MainApplication.java @@ -29,6 +29,7 @@ protected List getPackages() { List packages = new PackageList(this).getPackages(); // Packages that cannot be autolinked yet can be added manually here, for example: // packages.add(new MyReactNativePackage()); + packages.add(new ReactOverScrollPackage()); return packages; } diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java new file mode 100644 index 000000000..596807309 --- /dev/null +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java @@ -0,0 +1,29 @@ +package tonlabs.uikit; + +import com.facebook.react.ReactPackage; +import com.facebook.react.bridge.NativeModule; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.uimanager.ViewManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import javax.annotation.Nonnull; + +public class ReactOverScrollPackage implements ReactPackage { + @Nonnull + @Override + public List createNativeModules(@Nonnull ReactApplicationContext reactContext) { + return Collections.emptyList(); + } + + @Nonnull + @Override + public List createViewManagers(@Nonnull ReactApplicationContext reactContext) { + return Collections.emptyList(); +// List modules = new ArrayList<>(); +// modules.add(new ReactOverScrollViewManager()); +// return modules; + } +} \ No newline at end of file diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java new file mode 100644 index 000000000..3c0f2ba90 --- /dev/null +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -0,0 +1,322 @@ +package tonlabs.uikit; + +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.uimanager.PixelUtil; +import com.facebook.react.uimanager.UIManagerHelper; +import com.facebook.react.uimanager.events.NativeGestureUtil; +import com.facebook.react.views.scroll.ReactScrollView; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.MotionEvent; +import android.view.View; + +import androidx.core.view.MotionEventCompat; + +import com.facebook.react.views.scroll.ReactScrollViewHelper; +import com.facebook.react.views.scroll.ScrollEvent; +import com.facebook.react.views.scroll.ScrollEventType; +import com.mixiaoxiao.overscroll.OverScrollDelegate; +import com.mixiaoxiao.overscroll.OverScrollDelegate.OverScrollable; +import com.mixiaoxiao.overscroll.PathScroller; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; + +/** + * https://github.com/Mixiaoxiao/OverScroll-Everywhere + * + * @author Mixiaoxiao 2016-08-31 + */ +public class ReactOverScrollView extends ReactScrollView implements OverScrollable { + + private OverScrollDelegate mOverScrollDelegate; + + // =========================================================== + // Constructors + // =========================================================== + public ReactOverScrollView(ReactContext context) { + super(context); + createOverScrollDelegate(context); + } + + // =========================================================== + // createOverScrollDelegate + // =========================================================== + private void createOverScrollDelegate(Context context) { + mOverScrollDelegate = new OverScrollDelegate(this); + mOverScrollDelegate.setOverScrollStyle(new OverScrollStyle()); + mOverScrollDelegate.setOverScrollType(true, false); + } + + // =========================================================== + // Delegate + // =========================================================== + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + if (!getScrollEnabled()) { + return false; + } + + final int action = MotionEventCompat.getActionMasked(ev); + if (action == MotionEvent.ACTION_DOWN) { + setParentDragging(true); + } + + boolean parentIntercepted = super.onInterceptTouchEvent(ev); + boolean overScrollIntercepted = mOverScrollDelegate.onInterceptTouchEvent(ev); + + return parentIntercepted || overScrollIntercepted; + } + + Field parentDraggingField; + + boolean getParentDragging() { + try { + // TODO: create a wrapper with getter method + if (parentDraggingField == null) { + parentDraggingField = getClass().getSuperclass().getDeclaredField("mDragging"); //NoSuchFieldException + parentDraggingField.setAccessible(true); + } + return (boolean) parentDraggingField.get(this); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return false; + } + + void setParentDragging(boolean val) { + try { + // TODO: create a wrapper with getter method + if (parentDraggingField == null) { + parentDraggingField = getClass().getSuperclass().getDeclaredField("mDragging"); //NoSuchFieldException + parentDraggingField.setAccessible(true); + } + parentDraggingField.set(this, val); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + } + + Field mScrollEnabledField; + + boolean getScrollEnabled() { + try { + // TODO: create a wrapper with getter method + if (mScrollEnabledField == null) { + mScrollEnabledField = getClass().getSuperclass().getDeclaredField("mScrollEnabled"); //NoSuchFieldException + mScrollEnabledField.setAccessible(true); + } + return (boolean) mScrollEnabledField.get(this); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent ev) { + if (!getScrollEnabled()) { + return false; + } + + if (mOverScrollDelegate.onTouchEvent(ev)) { + int mState = getOverScrollState(); + if (mState == OverScrollDelegate.OS_DRAG_TOP || mState == OverScrollDelegate.OS_DRAG_BOTTOM) { + ReactContext reactContext = (ReactContext) this.getContext(); + int surfaceId = UIManagerHelper.getSurfaceId(reactContext); + Log.d("ReactOverScrollView", "custom onScroll"); + UIManagerHelper + .getEventDispatcherForReactTag(reactContext, this.getId()) + .dispatchEvent( + ScrollEvent.obtain( + surfaceId, + this.getId(), + ScrollEventType.SCROLL, + 0, + (int) (-1 * getOverScrollOffsetY() * ev.getYPrecision()), + 0, + 0, + 0, + 0, + 0, + 0)); + } + return true; + } + + return super.onTouchEvent(ev); + } + + Field overScrollStateField; + + int getOverScrollState() { + try { + // TODO: create a wrapper with getter method + if (overScrollStateField == null) { + overScrollStateField = mOverScrollDelegate.getClass().getDeclaredField("mState"); //NoSuchFieldException + overScrollStateField.setAccessible(true); + } + return (int) overScrollStateField.get(mOverScrollDelegate); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return 0; + } + + Field overScrollOffsetYField; + + float getOverScrollOffsetY() { + try { + // TODO: create a wrapper with getter method + if (overScrollOffsetYField == null) { + overScrollOffsetYField = mOverScrollDelegate.getClass().getDeclaredField("mOffsetY"); //NoSuchFieldException + overScrollOffsetYField.setAccessible(true); + } + return (float) overScrollOffsetYField.get(mOverScrollDelegate); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return 0.0F; + } + + Method superHandlePostTouchScrolling; + + public void callSuperHandlePostTouchScrolling(int velocityX, int velocityY) { + try { + if (superHandlePostTouchScrolling == null) { + superHandlePostTouchScrolling = Objects.requireNonNull(getClass().getSuperclass()).getDeclaredMethod("handlePostTouchScrolling", int.class, int.class); + superHandlePostTouchScrolling.setAccessible(true); + } + superHandlePostTouchScrolling.invoke(this, velocityX, velocityY); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } + } + + @Override + public void draw(Canvas canvas) { + mOverScrollDelegate.draw(canvas); + } + + @Override + protected boolean overScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, + int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) { + return mOverScrollDelegate.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, + maxOverScrollX, maxOverScrollY, isTouchEvent); + } + + // =========================================================== + // OverScrollable, aim to call view internal methods + // =========================================================== + + @Override + public int superComputeVerticalScrollExtent() { + return super.computeVerticalScrollExtent(); + } + + @Override + public int superComputeVerticalScrollOffset() { + return super.computeVerticalScrollOffset(); + } + + @Override + public int superComputeVerticalScrollRange() { + return super.computeVerticalScrollRange(); + } + + @Override + public void superOnTouchEvent(MotionEvent event) { + super.onTouchEvent(event); + } + + @Override + public void superDraw(Canvas canvas) { + super.draw(canvas); + } + + @Override + public boolean superAwakenScrollBars() { + return super.awakenScrollBars(); + } + + @Override + public boolean superOverScrollBy(int deltaX, int deltaY, int scrollX, int scrollY, int scrollRangeX, + int scrollRangeY, int maxOverScrollX, int maxOverScrollY, boolean isTouchEvent) { + return super.overScrollBy(deltaX, deltaY, scrollX, scrollY, scrollRangeX, scrollRangeY, maxOverScrollX, + maxOverScrollY, isTouchEvent); + } + + @Override + public View getOverScrollableView() { + return this; + } + + @Override + public OverScrollDelegate getOverScrollDelegate() { + return mOverScrollDelegate; + } + + @Override + public void scrollTo(int x, int y) { + try { + // TODO: create a wrapper with getter method + Field mStateField = mOverScrollDelegate.getClass().getDeclaredField("mState"); //NoSuchFieldException + mStateField.setAccessible(true); + int mState = (int) mStateField.get(mOverScrollDelegate); + + // TODO: create a wrapper with getter method + Field mOffsetYField = mOverScrollDelegate.getClass().getDeclaredField("mOffsetY"); //NoSuchFieldException + mOffsetYField.setAccessible(true); + + if (mState == OverScrollDelegate.OS_DRAG_TOP || mState == OverScrollDelegate.OS_DRAG_BOTTOM) { + mStateField.set(mOverScrollDelegate, OverScrollDelegate.OS_NONE); + mOffsetYField.set(mOverScrollDelegate, y); + invalidate(); + } + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + super.scrollTo(x, y); + } + + public static class OverScrollStyle extends OverScrollDelegate.OverScrollStyle { + public void transformOverScrollCanvas(float offsetY, Canvas canvas, View view) { + canvas.translate(0.0F, offsetY); + } + } +} \ No newline at end of file diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollViewManager.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollViewManager.java new file mode 100644 index 000000000..b0b1cb65f --- /dev/null +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollViewManager.java @@ -0,0 +1,28 @@ +package tonlabs.uikit; + +import androidx.annotation.NonNull; + +import com.facebook.react.module.annotations.ReactModule; +import com.facebook.react.uimanager.ThemedReactContext; +import com.facebook.react.views.scroll.ReactScrollView; +import com.facebook.react.views.scroll.ReactScrollViewManager; + +import org.jetbrains.annotations.NotNull; + + +@ReactModule(name = ReactOverScrollViewManager.REACT_CLASS) +public class ReactOverScrollViewManager extends ReactScrollViewManager { + public static final String REACT_CLASS = "RCTScrollView"; + + @NonNull + @NotNull + @Override + public ReactScrollView createViewInstance(ThemedReactContext reactContext) { + return new ReactOverScrollView(reactContext); + } + + @Override + public boolean canOverrideExistingModule() { + return true; + } +} diff --git a/Example/package.json b/Example/package.json index d903c0e46..c336ff2a4 100644 --- a/Example/package.json +++ b/Example/package.json @@ -93,6 +93,7 @@ "react-native-screens": "3.6.0", "react-native-share": "^3.8.3", "react-native-simple-popover": "git+https://github.com/tonlabs/react-native-simple-popover.git", + "react-native-simple-shadow-view": "1.6.3", "react-native-status-bar-height": "1.0.1", "react-native-svg": "^12.1.0", "react-native-view-shot": "3.1.2", diff --git a/Example/src/App.tsx b/Example/src/App.tsx index 5252091bc..35c86a0d7 100644 --- a/Example/src/App.tsx +++ b/Example/src/App.tsx @@ -7,7 +7,7 @@ import { FlatList, TouchableOpacity } from 'react-native-gesture-handler'; import React from 'react'; -import { Platform, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { useReduxDevToolsExtension } from '@react-navigation/devtools'; import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -36,8 +36,9 @@ import { UILargeTitleHeader, UISearchBarButton, } from '@tonlabs/uicast.bars'; -import { createSplitNavigator } from '@tonlabs/uicast.split-navigator'; import { ScrollView } from '@tonlabs/uikit.scrolls'; +import { UIAssets } from '@tonlabs/uikit.assets'; +import { createSplitNavigator, useSplitTabBarHeight } from '@tonlabs/uicast.split-navigator'; import { ButtonsScreen } from './screens/Buttons'; import { Checkbox } from './screens/Checkbox'; @@ -79,6 +80,7 @@ const Main = ({ navigation }: { navigation: any }) => { const themeSwitcher = React.useContext(ThemeSwitcher); const [isSearchVisible, setIsSearchVisible] = React.useState(false); const { top, bottom } = useSafeAreaInsets(); + const tabBarBottomInset = useSplitTabBarHeight(); return ( @@ -139,7 +141,17 @@ const Main = ({ navigation }: { navigation: any }) => { }} - + + navigation.navigate('large-header')} + layout={styles.button} + /> { type={UILinkButtonType.Menu} onPress={() => navigation.navigate('keyboard')} layout={styles.button} - /> - navigation.navigate('large-header')} - layout={styles.button} /> */} + { }, ], }, - ...Platform.select({ - android: { - stackAnimation: 'slide_from_right', - }, - default: null, - }), }} mainWidth={900} > @@ -346,8 +347,22 @@ const App = () => { - - + + diff --git a/Example/src/screens/LargeHeader.tsx b/Example/src/screens/LargeHeader.tsx index c09c5d349..3cd07a5fa 100644 --- a/Example/src/screens/LargeHeader.tsx +++ b/Example/src/screens/LargeHeader.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { UIMaterialTextView } from '@tonlabs/uikit.inputs'; @@ -28,17 +28,35 @@ function LargeHeaderExample() { }} /> )} - {new Array(9) + {new Array(19) .fill(null) - .map((_el, i) => (i + 1) / 10) + .map((_el, i) => (i + 1) / 19) .map(opacity => ( + > + {new Array(5) + .fill(null) + .map((_el, i) => (i + 1) / 5) + .map(opacity => ( + + ))} + ))} ); @@ -110,7 +128,7 @@ export function LargeHeaderScreen() { // onTitlePress: () => { // console.log('sdfsdf'); // }, - // caption: 'caption', + caption: 'caption', // headerRightItems: [ // { // label: 'Action1', @@ -121,10 +139,10 @@ export function LargeHeaderScreen() { // onPress: () => {}, // }, // ], - renderAboveContent: () => { - return ; - }, - // renderBelowContent, + // renderAboveContent: () => { + // return ; + // }, + renderBelowContent, }} component={LargeHeaderExample} /> diff --git a/casts/bars/src/UILargeTitleHeader/index.tsx b/casts/bars/src/UILargeTitleHeader/index.tsx index b613879e9..e79544e37 100644 --- a/casts/bars/src/UILargeTitleHeader/index.tsx +++ b/casts/bars/src/UILargeTitleHeader/index.tsx @@ -8,6 +8,7 @@ import Animated, { Extrapolate, interpolate, useAnimatedReaction, + useDerivedValue, scrollTo, } from 'react-native-reanimated'; import { TouchableOpacity } from '@tonlabs/uikit.controls'; @@ -15,6 +16,8 @@ import { UIBackgroundView, UILabel, UILabelColors, UILabelRoles } from '@tonlabs import { useHasScroll, ScrollableContext } from '@tonlabs/uikit.scrolls'; import { UILayoutConstant } from '@tonlabs/uikit.layout'; +import { getYWithRubberBandEffect } from '@tonlabs/uikit.popups'; + import { UIConstant } from '../constants'; import type { UINavigationBarProps } from '../UINavigationBar'; import { UIStackNavigationBar } from '../UIStackNavigationBar'; @@ -124,7 +127,7 @@ export function UILargeTitleHeader({ /** * Sometimes it's needed to invalidate a height of large title */ - if (largeTitleHeight.value > 0 && largeTitleHeight.value !== height) { + if (largeTitleHeight.value !== height) { largeTitleHeight.value = height; } }, @@ -137,24 +140,38 @@ export function UILargeTitleHeader({ const { hasScroll, hasScrollShared, setHasScroll } = useHasScroll(); - const { scrollInProgress, scrollHandler, gestureHandler, onWheel } = useScrollHandler( + const { scrollHandler, onWheel } = useScrollHandler( scrollRef, largeTitleViewRef, shift, defaultShift, largeTitleHeight, hasScrollShared, - RUBBER_BAND_EFFECT_DISTANCE, ); - const style = useAnimatedStyle(() => { + const translateY = useDerivedValue(() => { + if (shift.value > 0) { + return getYWithRubberBandEffect(shift.value, RUBBER_BAND_EFFECT_DISTANCE); + } + return Math.max(shift.value, -largeTitleHeight.value); + }); + const headerStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: translateY.value, + }, + ], + }; + }); + const scrollableStyle = useAnimatedStyle(() => { + return { + marginTop: translateY.value + largeTitleHeight.value, + }; return { transform: [ { - translateY: - largeTitleHeight.value > 0 - ? Math.max(shift.value, -largeTitleHeight.value) - : shift.value, + translateY: translateY.value + largeTitleHeight.value, }, ], }; @@ -168,7 +185,7 @@ export function UILargeTitleHeader({ transform: [ { scale: interpolate( - shift.value, + translateY.value, [0, RUBBER_BAND_EFFECT_DISTANCE], [1, LARGE_TITLE_SCALE], { @@ -178,7 +195,7 @@ export function UILargeTitleHeader({ }, { translateX: interpolate( - shift.value, + translateY.value, [0, RUBBER_BAND_EFFECT_DISTANCE], [0, (titleWidth.value * LARGE_TITLE_SCALE - titleWidth.value) / 2], { @@ -247,7 +264,8 @@ export function UILargeTitleHeader({ ref: scrollRef, panGestureHandlerRef, scrollHandler, - gestureHandler, + // TODO: remove + gestureHandler: null, onWheel, hasScroll, setHasScroll, @@ -258,7 +276,6 @@ export function UILargeTitleHeader({ scrollRef, panGestureHandlerRef, scrollHandler, - gestureHandler, onWheel, hasScroll, setHasScroll, @@ -321,15 +338,15 @@ export function UILargeTitleHeader({ callback?: ((isFinished: boolean) => void) | undefined, ) => { // Do not interupt active scroll - if (!scrollInProgress.value) { - shift.value = withTiming(position, { duration: options.duration ?? 0 }, callback); - scrollTo(scrollRef, 0, 0, false); - } + // if (!scrollInProgress.value) { + shift.value = withTiming(position, { duration: options.duration ?? 0 }, callback); + scrollTo(scrollRef, 0, 0, false); + // } if (options.changeDefaultShift) { defaultShift.value = position; } }, - [shift, defaultShift, scrollInProgress, scrollRef], + [shift, defaultShift, scrollRef], ); const positionContext = React.useMemo( @@ -343,8 +360,22 @@ export function UILargeTitleHeader({ return ( - - + + {renderAboveContent && renderAboveContent()} @@ -367,19 +398,21 @@ export function UILargeTitleHeader({ {/* TODO(savelichalex): This is a huge hack for UIController measurement mechanics need to get rid of it as soon as we'll manage to remove UIController */} - - - - {children} - - - + + + + + {children} + + + + , largeTitleViewRef: React.RefObject, - shift: Animated.SharedValue, - defaultShift: Animated.SharedValue, + currentPosition: Animated.SharedValue, + defaultPosition: Animated.SharedValue, largeTitleHeight: Animated.SharedValue, hasScrollShared: Animated.SharedValue, - rubberBandDistance: number, ) { - const scrollInProgress = useSharedValue(false); // see `useAnimatedGestureHandler` and `onWheel` const yIsNegative = useSharedValue(true); const { parentHandler: parentScrollHandler, parentHandlerActive: parentScrollHandlerActive } = useScrollableParentScrollHandler(); - const onBeginDrag = useOnBeginDrag(shift, scrollInProgress, parentScrollHandler); + const mightApplyShiftToScrollView = useSharedValue(false); + + const onBeginDrag = useOnBeginDrag( + currentPosition, + mightApplyShiftToScrollView, + parentScrollHandler, + ); const onScroll = useOnScrollHandler( scrollRef, largeTitleViewRef, largeTitleHeight, - yIsNegative, - shift, - rubberBandDistance, + currentPosition, parentScrollHandler, parentScrollHandlerActive, ); - const mightApplyShiftToScrollView = useSharedValue(false); - useAnimatedReaction( () => { return { - shift: shift.value, + currentPosition: currentPosition.value, largeTitleHeight: largeTitleHeight.value, mightApplyShiftToScrollView: mightApplyShiftToScrollView.value, }; }, state => { + // console.log(state.currentPosition, state.largeTitleHeight); if (!state.mightApplyShiftToScrollView) { return; } - scrollTo(scrollRef, 0, 0 - state.shift - state.largeTitleHeight, false); + if (state.currentPosition < 0 - state.largeTitleHeight) { + // console.log( + // state.currentPosition, + // // eslint-disable-next-line no-bitwise + // ~(state.currentPosition + state.largeTitleHeight) + 1, + // ); + scrollTo( + scrollRef, + 0, + // eslint-disable-next-line no-bitwise + -1 * (state.currentPosition + state.largeTitleHeight), + false, + ); + } }, ); const onEndDrag = useOnEndDrag( - shift, - scrollInProgress, + currentPosition, largeTitleHeight, - defaultShift, + defaultPosition, mightApplyShiftToScrollView, parentScrollHandler, parentScrollHandlerActive, ); - const onMomentumEnd = useOnMomentumEnd(shift, scrollInProgress, defaultShift, largeTitleHeight); + const onMomentumEnd = useOnMomentumEnd(currentPosition, defaultPosition, largeTitleHeight); const scrollHandler = useAnimatedScrollHandler({ onBeginDrag, @@ -80,12 +92,6 @@ export function useScrollHandler( onMomentumEnd, }); - const gestureHandler = useScrollFallbackGestureHandler( - hasScrollShared, - yIsNegative, - scrollHandler as any, - ); - /** * On web listening to `scroll` events is not enough, * because when it reaches the end (y is 0) @@ -99,9 +105,7 @@ export function useScrollHandler( const onWheel = useOnWheelHandler(yIsNegative, hasScrollShared, scrollHandler as any); return { - scrollInProgress, scrollHandler, - gestureHandler, onWheel, }; } diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts new file mode 100644 index 000000000..bd71b02e1 --- /dev/null +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts @@ -0,0 +1,106 @@ +// @inline +const SCROLL_NONE = 0; +// @inline +const SCROLL_DRAGGING = 1; +// @inline +const SCROLL_FLING_EMULATED = 2; +// @inline +const SCROLL_FLING_REAL = 3; + +type ScrollState = 0 | 1 | 2 | 3; + +export type ScrollHandlerContext = { + lastScrollTimeMs: number; + velocityY: number; + state: ScrollState; +}; + +export function isNoScroll(ctx: Context) { + 'worklet'; + + return ctx.state === SCROLL_NONE; +} + +export function setNoScroll(ctx: Context) { + 'worklet'; + + ctx.state = SCROLL_NONE; +} + +export function isDragging(ctx: Context) { + 'worklet'; + + return ctx.state === SCROLL_DRAGGING; +} + +export function setDragging(ctx: Context) { + 'worklet'; + + ctx.state = SCROLL_DRAGGING; +} + +export function isFlingEmulated(ctx: Context) { + 'worklet'; + + return ctx.state === SCROLL_FLING_EMULATED; +} + +export function setFlingEmulated(ctx: Context) { + 'worklet'; + + ctx.state = SCROLL_FLING_EMULATED; +} + +export function isFlingReal(ctx: Context) { + 'worklet'; + + return ctx.state === SCROLL_FLING_REAL; +} + +export function setFlingReal(ctx: Context) { + 'worklet'; + + ctx.state = SCROLL_FLING_REAL; +} + +export function getStateDescription(ctx: Context) { + 'worklet'; + + if (ctx.state === SCROLL_NONE) { + return 'none'; + } + if (ctx.state === SCROLL_DRAGGING) { + return 'dragging'; + } + if (ctx.state === SCROLL_FLING_EMULATED) { + return 'fling emulated'; + } + if (ctx.state === SCROLL_FLING_REAL) { + return 'fling real'; + } + return 'unknown'; +} + +export function initVelocityTracker(ctx: Context) { + 'worklet'; + + ctx.lastScrollTimeMs = Date.now(); +} + +export function trackVelocity( + diff: number, + ctx: Context, +) { + 'worklet'; + + const now = Date.now(); + + if (diff === 0) { + return; + } + + const velocityY = diff / (now - ctx.lastScrollTimeMs); + + ctx.velocityY = velocityY; + ctx.lastScrollTimeMs = now; +} diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnBeginDrag.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnBeginDrag.tsx index 57ccb6200..5ee8ac9eb 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnBeginDrag.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnBeginDrag.tsx @@ -3,11 +3,12 @@ import * as React from 'react'; import type { NativeScrollEvent } from 'react-native'; import Animated, { cancelAnimation } from 'react-native-reanimated'; import type { ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; -import type { ScrollHandlerContext } from '../types'; +import { initVelocityTracker, setDragging } from './scrollContext'; +import type { ScrollHandlerContext } from './scrollContext'; export function useOnBeginDrag( - shift: Animated.SharedValue, - scrollInProgress: Animated.SharedValue, + currentPosition: Animated.SharedValue, + mightApplyShiftToScrollView: Animated.SharedValue, parentScrollHandler: ScrollableParentScrollHandler, ) { const onBeginHandlerRef = React.useRef< @@ -18,13 +19,15 @@ export function useOnBeginDrag( onBeginHandlerRef.current = (_event: NativeScrollEvent, ctx: ScrollHandlerContext) => { 'worklet'; - cancelAnimation(shift); + console.log('onBeginDrag'); - ctx.scrollTouchGuard = true; - ctx.continueResetOnMomentumEnd = false; - ctx.yWithoutRubberBand = shift.value; - scrollInProgress.value = true; + cancelAnimation(currentPosition); + setDragging(ctx); + initVelocityTracker(ctx); + mightApplyShiftToScrollView.value = false; + + // TODO: check it parentScrollHandler(_event); }; } diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx index dbf1f7560..fe4dc5dde 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx @@ -1,14 +1,21 @@ /* eslint-disable no-param-reassign */ import * as React from 'react'; -import type { NativeScrollEvent } from 'react-native'; -import Animated, { withSpring, withDecay } from 'react-native-reanimated'; +import type { ScrollView as RNScrollView, NativeScrollEvent } from 'react-native'; +import Animated, { withSpring, withDecay, scrollTo } from 'react-native-reanimated'; import type { ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; import type { ScrollHandlerContext } from '../types'; import { runOnUIPlatformSelect } from './runOnUIPlatformSelect'; +import { + isDragging, + setFlingEmulated, + setFlingReal, + setNoScroll, +} from './scrollContext'; function normalizedEnd( - shift: Animated.SharedValue, - scrollInProgress: Animated.SharedValue, + currentPosition: Animated.SharedValue, + largeTitleHeight: Animated.SharedValue, + mightApplyShiftToScrollView: Animated.SharedValue, parentScrollHandler: ScrollableParentScrollHandler, parentScrollHandlerActive: boolean, ) { @@ -29,115 +36,284 @@ function normalizedEnd( return (event: NativeScrollEvent, ctx: ScrollHandlerContext) => { 'worklet'; - if (event && parentScrollHandlerActive) { - if (ctx.yWithoutRubberBand > 0) { - parentScrollHandler(event); - return; - } - } + console.log( + 'onBeginEnd', + // event.contentOffset.y, + // event.velocity?.y, + // TODO + // @ts-expect-error + ctx.velocityY, + // TODO + /** + * This one can't be used to determine next position + * as user can just stop at some point for some undefined + * time and move a finger, and only then move it up. + */ + // @ts-expect-error + // ctx.velocityY * (Date.now() - ctx.lastScrollTimeMs), + ); + + const { velocityY } = ctx; + + /** + * Terminology: + * - "Up" is when user moves a finger up + * - "Down" is when one moves a finger down + * + * This important, as actually finger movement is opposite + * to what direction content moves. + */ - if (event != null && event.velocity != null && ctx != null) { - const isUpwardMotion = runOnUIPlatformSelect({ - android: event.velocity.y > 0, - default: event.velocity.y < 0, - }); + const isDownMotion = velocityY > 0; + + if (isDownMotion) { /** - * First of all handle upward motion - * This is when a header possibly could be shown + * TODO: maybe handle it outside to make it easier to read? */ - if (isUpwardMotion) { - /** - * At first we handle a situation - * when the end event occurred while large header was "extended" - * over the limits and when we apply a rubber band effect. - * Doing it the same way as iOS does - just return it to the nearest position. - */ - if (shift.value > 0) { - shift.value = withSpring(0, { + /** + * At first we handle a situation + * when the end event occurred while large header was "extended" + * over the limits and when we apply a rubber band effect. + * Doing it the same way as iOS does - just return it to the nearest position. + */ + if (currentPosition.value > 0) { + setFlingEmulated(ctx); + currentPosition.value = withSpring( + 0, + { overshootClamping: true, - }); - scrollInProgress.value = false; - return; - } - /** - * Next we look if the header became visible. - * Here we handle when it doesn't. - * That means that we should wait until `onMomentumEnd` fired - * as ScrollView will continue to fire regular `onScroll` events until it. - */ - if (event.contentOffset.y > 0) { - if (ctx != null) { - ctx.continueResetOnMomentumEnd = true; - } - - return; - } - - /** - * Velocity for iOS is reverted due to incostistensy between - * how we calculate the shift and how a platform see it. - * - * On Android velocity is very low and decay animation ends very fast, - * (partly because `withDecay` ends animation when velocity is lesser then 1) - * so to prolong it multiply by 5 (I just chose a random number). - * (The same thing for iOS but it's put for velocityFactor, - * it just felt better there, no specific technical reason). - * - * Velocity factor is choosen with an eye test: - * - on iOS 500 should be read as 5 * 100, where: - * * 100 is a multiplier of velocity as iOS gives very little - * velocity value, that results to a very fast decoy animation ending; - * * 5 was choosen with eye test, just to make it feel - * like a continuation of the original scroll view movement. - */ - movementHandlers.onUpwardDeceleration( - runOnUIPlatformSelect({ - ios: -1 * event.velocity.y, - android: event.velocity.y * 5, - default: event.velocity.y, - }), - runOnUIPlatformSelect({ - ios: 500, - default: 100, - }), - ctx, + }, + () => { + // Was intercepted + if (isDragging(ctx)) { + return; + } + setNoScroll(ctx); + }, ); + return; + } + /** + * TODO: this is not entirely true + * as we do `scrollTo(1)` + * to bottom motion, though coY can be ~1 + * need sth smarter than that + */ + /** + * Next we look if the header became visible. + * Here we handle when it doesn't. + * That means that we should wait until `onMomentumEnd` fired + * as ScrollView will continue to fire regular `onScroll` events until it. + */ + /** + * TODO: remove 1.1 ASAP!!!! + */ + if (currentPosition.value < -largeTitleHeight.value) { + setFlingReal(ctx); + console.log('fling real'); return; } + /** - * Just fun story about scrolling on Android: - * When you scrolling with your finger imagine the moment - * when you release it, you actually can be surprised, but - * actually there could be a backward movement that your pad - * of a finger can produce, when it released, so at the end - * you can get slight velocity in backward direction. - * In oreder to reduce it treat such values as the ones - * without inertia. - * We achive this by accepting velocity values only more than 0.6. + * For Android: + * Ideal situation is to create a JSI-reanimated + * HostObject around native SplineOverScroller (from OverScroller) + * and apply custom animation here. That way it would be 100% + * identical to Android native scroll fling animation. + * + * For now just use "eye tested" coefficients for withDecay */ - const isToBottomMotion = runOnUIPlatformSelect({ - android: event.velocity.y < -0.6, - default: event.velocity.y > 0, - }); - if (isToBottomMotion) { - // Nothing to do, regular scroll - if (event.contentOffset.y > 0) { - return; - } - - movementHandlers.onToBottomDeceleration( - -1 * ctx.lastApproximateVelocity, - runOnUIPlatformSelect({ - android: 70, - default: 50, - }), - ctx, - ); + setFlingEmulated(ctx); + currentPosition.value += velocityY; + currentPosition.value = withDecay( + { + velocity: velocityY * 1500, + // velocityFactor, + clamp: [0 - largeTitleHeight.value, 40], + }, + isFinished => { + if (isFinished) { + if (currentPosition.value > 0) { + currentPosition.value = withSpring(0, { + velocity: 1, + overshootClamping: true, + }); + } + // TODO: there was defaultShift instead of 0 + // currentPosition.value = withSpring(0, { + // velocity: 1, + // overshootClamping: true, + // }); + } + // Was intercepted + if (isDragging(ctx)) { + return; + } + setNoScroll(ctx); + }, + ); + return; + } + + const isUpMotion = velocityY < 0; + + if (isUpMotion) { + if (currentPosition.value < -largeTitleHeight.value) { + setFlingReal(ctx); + console.log('fling real'); return; } + // Nothing to do, regular scroll + // if (event.contentOffset.y > 1.1) { + // // if ( + // // event.contentOffset.y > runOnUIPlatformSelect({ android: 1.1, default: 0 }) + // // ) { + // setFlingReal(ctx); + // return; + // } + + setFlingEmulated(ctx); + mightApplyShiftToScrollView.value = true; + currentPosition.value += velocityY; + currentPosition.value = withDecay( + { + velocity: velocityY * 150, + // velocityFactor, + }, + () => { + // Was intercepted + if (isDragging(ctx)) { + return; + } + setNoScroll(ctx); + mightApplyShiftToScrollView.value = false; + // ctx.scrollTouchGuard = true; + // if (currentPosition.value + largeTitleHeight.value > 0) { + // currentPosition.value = withSpring(0 - largeTitleHeight.value, { + // velocity: 1, + // overshootClamping: true, + // }); + // } + // scrollInProgress.value = false; + }, + ); } - movementHandlers.onWithoutDeceleration(); + + // if (event && parentScrollHandlerActive) { + // if (ctx.yWithoutRubberBand > 0) { + // parentScrollHandler(event); + // return; + // } + // } + + // console.log( + // 'onBeginEnd', + // event.contentOffset.y, + // event.velocity?.y, + // ctx.lastApproximateVelocity, + // ); + + // if (event != null && event.velocity != null && ctx != null) { + // const velocity = runOnUIPlatformSelect({ + // android: androidGetVelocity, + // default: defaultGetVelocity, + // })(event, ctx); + // const isUpwardMotion = velocity < 0; + // /** + // * First of all handle upward motion + // * This is when a header possibly could be shown + // */ + // if (isUpwardMotion) { + // console.log('isUpwardMotion', 1); + // /** + // * At first we handle a situation + // * when the end event occurred while large header was "extended" + // * over the limits and when we apply a rubber band effect. + // * Doing it the same way as iOS does - just return it to the nearest position. + // */ + // if (shift.value > 0) { + // console.log('isUpwardMotion', 2); + // shift.value = withSpring(0, { + // overshootClamping: true, + // }); + // scrollInProgress.value = false; + // return; + // } + // /** + // * Next we look if the header became visible. + // * Here we handle when it doesn't. + // * That means that we should wait until `onMomentumEnd` fired + // * as ScrollView will continue to fire regular `onScroll` events until it. + // */ + // if (event.contentOffset.y > 0) { + // console.log('isUpwardMotion', 3); + // if (ctx != null) { + // ctx.continueResetOnMomentumEnd = true; + // } + + // return; + // } + + // /** + // * Velocity for iOS is reverted due to incostistensy between + // * how we calculate the shift and how a platform see it. + // * + // * On Android velocity is very low and decay animation ends very fast, + // * (partly because `withDecay` ends animation when velocity is lesser then 1) + // * so to prolong it multiply by 5 (I just chose a random number). + // * (The same thing for iOS but it's put for velocityFactor, + // * it just felt better there, no specific technical reason). + // * + // * Velocity factor is choosen with an eye test: + // * - on iOS 500 should be read as 5 * 100, where: + // * * 100 is a multiplier of velocity as iOS gives very little + // * velocity value, that results to a very fast decoy animation ending; + // * * 5 was choosen with eye test, just to make it feel + // * like a continuation of the original scroll view movement. + // */ + // console.log('isUpwardMotion', 4); + // movementHandlers.onUpwardDeceleration( + // runOnUIPlatformSelect({ + // ios: -1 * velocity, + // android: -1 * velocity, + // default: event.velocity.y, + // }), + // runOnUIPlatformSelect({ + // ios: 500, + // default: 100, + // }), + // ctx, + // ); + + // return; + // } + + // const isToBottomMotion = velocity > 0; + // if (isToBottomMotion) { + // console.log('isToBottomMotion', 1); + // // Nothing to do, regular scroll + // if ( + // event.contentOffset.y > + // runOnUIPlatformSelect({ android: 1.1, default: 0 }) + // ) { + // console.log('isToBottomMotion', 2); + // return; + // } + + // console.log('isToBottomMotion', 3); + // movementHandlers.onToBottomDeceleration( + // -1 * ctx.lastApproximateVelocity, + // runOnUIPlatformSelect({ + // android: 70, + // default: 50, + // }), + // ctx, + // ); + // return; + // } + // } + // movementHandlers.onWithoutDeceleration(); }; }, }; @@ -145,7 +321,6 @@ function normalizedEnd( export function useOnEndDrag( shift: Animated.SharedValue, - scrollInProgress: Animated.SharedValue, largeTitleHeight: Animated.SharedValue, defaultShift: Animated.SharedValue, mightApplyShiftToScrollView: Animated.SharedValue, @@ -159,7 +334,8 @@ export function useOnEndDrag( if (onEndHandlerRef.current == null) { onEndHandlerRef.current = normalizedEnd( shift, - scrollInProgress, + largeTitleHeight, + mightApplyShiftToScrollView, parentScrollHandler, parentScrollHandlerActive, ).with({ @@ -176,22 +352,24 @@ export function useOnEndDrag( onUpwardDeceleration(velocity, velocityFactor) { 'worklet'; - shift.value = withDecay( - { - velocity, - velocityFactor, - clamp: [0 - largeTitleHeight.value, 0], - }, - isFinished => { - if (isFinished) { - shift.value = withSpring(defaultShift.value, { - velocity: 1, - overshootClamping: true, - }); - } - scrollInProgress.value = false; - }, - ); + console.log('onUpwardDeceleration'); + + // shift.value = withDecay( + // { + // velocity, + // velocityFactor, + // clamp: [0 - largeTitleHeight.value, 0], + // }, + // isFinished => { + // if (isFinished) { + // shift.value = withSpring(defaultShift.value, { + // velocity: 1, + // overshootClamping: true, + // }); + // } + // scrollInProgress.value = false; + // }, + // ); }, /** * At the point goes the probably hardest case. @@ -211,26 +389,28 @@ export function useOnEndDrag( onToBottomDeceleration(velocity, velocityFactor, ctx) { 'worklet'; - mightApplyShiftToScrollView.value = true; - ctx.scrollTouchGuard = false; - shift.value = withDecay( - { - velocity, - velocityFactor, - }, - () => { - mightApplyShiftToScrollView.value = false; - ctx.scrollTouchGuard = true; - - if (shift.value + largeTitleHeight.value > 0) { - shift.value = withSpring(0 - largeTitleHeight.value, { - velocity: 1, - overshootClamping: true, - }); - } - scrollInProgress.value = false; - }, - ); + console.log('onToBottomDeceleration'); + + // mightApplyShiftToScrollView.value = true; + // ctx.scrollTouchGuard = false; + // shift.value = withDecay( + // { + // velocity, + // velocityFactor, + // }, + // () => { + // mightApplyShiftToScrollView.value = false; + // ctx.scrollTouchGuard = true; + + // if (shift.value + largeTitleHeight.value > 0) { + // shift.value = withSpring(0 - largeTitleHeight.value, { + // velocity: 1, + // overshootClamping: true, + // }); + // } + // scrollInProgress.value = false; + // }, + // ); }, /** * If we got to the point that means @@ -241,10 +421,12 @@ export function useOnEndDrag( onWithoutDeceleration() { 'worklet'; + console.log('onWithoutDeceleration'); + function onSpringEnd() { 'worklet'; - scrollInProgress.value = false; + // scrollInProgress.value = false; } /** diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx index 002a1b625..13caf5042 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx @@ -2,16 +2,15 @@ import * as React from 'react'; import type { NativeScrollEvent } from 'react-native'; import Animated, { withSpring, withDecay } from 'react-native-reanimated'; -import type { ScrollHandlerContext } from '../types'; +import type { ScrollHandlerContext } from './scrollContext'; import { runOnUIPlatformSelect } from './runOnUIPlatformSelect'; -function withNormalizedMomentumEnd( - scrollInProgress: Animated.SharedValue, - cb: (velocity: number, velocityFactor: number) => void, -) { +function withNormalizedMomentumEnd(cb: (velocity: number, velocityFactor: number) => void) { return (event: NativeScrollEvent, ctx: ScrollHandlerContext) => { 'worklet'; + return; + /** * If we got there then there was an end event * while scroll view was in motion @@ -24,7 +23,6 @@ function withNormalizedMomentumEnd( * ScrollView already did all the necessary work. */ if (event.contentOffset.y > 0) { - scrollInProgress.value = false; return; } /** @@ -64,9 +62,8 @@ function withNormalizedMomentumEnd( } export function useOnMomentumEnd( - shift: Animated.SharedValue, - scrollInProgress: Animated.SharedValue, - defaultShift: Animated.SharedValue, + currentPosition: Animated.SharedValue, + defaultPosition: Animated.SharedValue, largeTitleHeight: Animated.SharedValue, ) { const onMomentumEndRef = React.useRef< @@ -74,39 +71,35 @@ export function useOnMomentumEnd( >(null); if (onMomentumEndRef.current == null) { - onMomentumEndRef.current = withNormalizedMomentumEnd( - scrollInProgress, - (velocity, velocityFactor) => { - 'worklet'; + onMomentumEndRef.current = withNormalizedMomentumEnd((velocity, velocityFactor) => { + 'worklet'; - /** - * At the point `shift` might be not synced with actual position, - * but fortunately we can sync it right now. - * We know that `onMomuntumEnd` was fired - * when scroll view had reached 0 y coordinate. - * Hence the shift should be the size of largeTitleHeader. - * Since animation will be fired only on the next frame, to not skip frame - * and make it smoother also applying current velocity now. - */ - shift.value = -largeTitleHeight.value + velocity; - shift.value = withDecay( - { - velocity, - velocityFactor, - clamp: [0 - largeTitleHeight.value, 0], - }, - isFinished => { - if (isFinished) { - shift.value = withSpring(defaultShift.value, { - velocity: 1, - overshootClamping: true, - }); - } - scrollInProgress.value = false; - }, - ); - }, - ); + /** + * At the point `shift` might be not synced with actual position, + * but fortunately we can sync it right now. + * We know that `onMomuntumEnd` was fired + * when scroll view had reached 0 y coordinate. + * Hence the shift should be the size of largeTitleHeader. + * Since animation will be fired only on the next frame, to not skip frame + * and make it smoother also applying current velocity now. + */ + currentPosition.value = -largeTitleHeight.value + velocity; + currentPosition.value = withDecay( + { + velocity, + velocityFactor, + clamp: [0 - largeTitleHeight.value, 0], + }, + isFinished => { + // if (isFinished) { + // currentPosition.value = withSpring(defaultPosition.value, { + // velocity: 1, + // overshootClamping: true, + // }); + // } + }, + ); + }); } return onMomentumEndRef.current || undefined; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/index.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/index.tsx index b9fcd9926..a0bde3621 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/index.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/index.tsx @@ -11,9 +11,7 @@ export function useOnScrollHandler( scrollRef: React.RefObject, largeTitleViewRef: React.RefObject, largeTitleHeight: Animated.SharedValue, - yIsNegative: Animated.SharedValue, - shift: Animated.SharedValue, - rubberBandDistance: number, + currentPosition: Animated.SharedValue, parentScrollHandler: ScrollableParentScrollHandler, parentScrollHandlerActive: boolean, ) { @@ -24,9 +22,7 @@ export function useOnScrollHandler( scrollRef, largeTitleViewRef, largeTitleHeight, - yIsNegative, - shift, - rubberBandDistance, + currentPosition, parentScrollHandler, parentScrollHandlerActive, ) as (event: NativeScrollEvent) => void; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index 7778624eb..a397a80a3 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -6,12 +6,272 @@ import type { ScrollView as RNScrollView, NativeScrollEvent } from 'react-native import { getYWithRubberBandEffect } from '@tonlabs/uikit.popups'; import type { ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; -import type { ScrollHandlerContext } from '../../types'; +import { + trackVelocity, + isDragging, + isFlingReal, + getStateDescription, + isFlingEmulated, +} from '../scrollContext'; +import type { ScrollHandlerContext } from '../scrollContext'; const isIOS = Platform.OS === 'ios'; +/** + * Ok, so what scenario we can be at the moment? + * Let's start with the first render and the first touch event that might happen + * All is stable at the moment and we have all in the initial place + * That mean, that our starting point should be 0 at the point. + * + * From here we have two possible scenarious: + * 1. up + * 2. down + * + * in both those scenarious we will reset scrolling for scroll view, + * so that mean, that contentOffset.y (`coY`) won't represent actual coords, + * it's just kinda a difference between events. + * (TODO: can we guarantee that scrollTo will happen exactly between those events? + * what if not? + * what should we do then? + * can we put a guard somehow? + * * hypothesis to check - is it fires an event with `coY` == 0? it will be so easier this way) + * * another hypothesis to check, so the thought is that (looks very weak) + * when movement continues, the `coY` will increase + * until the reset will happen. So when the coord is less than the previous diff + * we may distinct it. A lot of problems actually: + * - What if one started to pull it very fast + * then we might get a `coY` that is bigger than + * all the previous diff. + * - What if one in between starts to move at the opposite direction? + * that would break all the logic. Same with straifing. + * + * At the moment we should just apply that diff to our context variable. + * + * The to the bottom movement is actually pretty easy to handle, + * just apply diff and calculate the rubber band effect. + * + * The upward movement is a bit more interesting + * as there we should apply `scrollTo` only until + * (currentCoord - coY) > -1 * headerHeight + * (TODO: we actually might distinct when coord become bigger than headerHeight + * and apply scrollTo with proper offset (do we really need this?)) + * + * The rest is not that interesting, as it's just a usual scroll. + * (TODO: should we track our coord here, just not to lose anything, + * or it's better to do less work there?) + */ + +function getNextPosition(event: NativeScrollEvent, currentPosition: number, headerHeight: number) { + 'worklet'; + + const { y } = event.contentOffset; + + // regular scroll + if (y > 0 && currentPosition + headerHeight <= 0) { + // TODO: should we keep it sync. Maybe good to not lose anything. Debatable though. + + // Just trying to save every ms here + // as events could be fired very fast + // need to do as little as possible + // (it doesn't save much, but anyway) + // https://jsbench.me/8ikwaptgyt/1 + // eslint-disable-next-line no-bitwise + return -1 * (y + headerHeight); + } + // to bottom, rubber band effect + // TODO: see above about determenism of scrollTo + if (currentPosition >= 0) { + return currentPosition - y; + } + // upward, collapsing + // TODO: see above about determenism of scrollTo + return currentPosition - y; +} + +function foo(event: NativeScrollEvent) { + const currentPosition = 0; + const headerHeight = 0; + + return 1; +} + +function test() { + function assert(income: any, expected: any) { + if (income === expected) { + console.log('correct!'); + } else { + console.log('incorrect!'); + } + } + + // regular scroll + assert(getNextPosition({ contentOffset: { y: 5 } } as any, -50, 50), -55); + // rubber band + assert(getNextPosition({ contentOffset: { y: -5 } } as any, 10, 50), 15); + assert(getNextPosition({ contentOffset: { y: 5 } } as any, 10, 50), 5); + // collapsible area + assert(getNextPosition({ contentOffset: { y: -5 } } as any, -10, 50), -5); + assert(getNextPosition({ contentOffset: { y: 5 } } as any, -10, 50), -15); +} + +export default function createOnScroll( + scrollRef: React.RefObject, + largeTitleViewRef: React.RefObject, + largeTitleHeight: Animated.SharedValue, + currentPosition: Animated.SharedValue, + parentScrollHandler: ScrollableParentScrollHandler, + parentScrollHandlerActive: boolean, +) { + return (event: NativeScrollEvent, ctx: ScrollHandlerContext) => { + 'worklet'; + + const { y } = event.contentOffset; + + // On Android when a content is less than scrollable area + // onScroll event can return NaN y, that we can't process. + if (Number.isNaN(y)) { + return; + } + + /** + * TODO: rephrase it + * + * The fix is needed only for iOS + * + * On iOS `onScroll` event could fire on mount sometimes, + * that's likely a bug in RN or iOS itself. + * To prevent changes when there wasn't onBeginDrag event + * (so it's likely not an actual scroll) using a guard + */ + // if (!(isDragging(ctx) || isFlingReal(ctx))) { + // console.log('weird onScroll', getStateDescription(ctx)); + // return; + // } + + if (largeTitleHeight.value === 0) { + try { + // Comment the next line as `try - catch` can't handle errors from another `worklet`: + // largeTitleHeight.value = measure(largeTitleViewRef).height || 0; + + // That's why let's run it by our own as per `measure` implementation in Reanimated: + // https://github.com/software-mansion/react-native-reanimated/blob/1d698d83c6f041603d548bf10d47eab992e50840/src/reanimated2/NativeMethods.ts#L20 + // @ts-ignore + largeTitleHeight.value = _measure(largeTitleViewRef()).height || 0; + } catch (e) { + // nothing + } + } + + if (y === 0) { + // TODO: this is very important! + console.log('skipped'); + return; + } + + if (ctx.skipOnNextScroll) { + // scrollTo(scrollRef, 0, 0, false); + console.log('skipped****'); + ctx.skipOnNextScroll = false; + return; + } + + const nextPosition = getNextPosition(event, currentPosition.value, largeTitleHeight.value); + const diff = nextPosition - currentPosition.value; + + // console.log(currentPosition.value, nextPosition, diff, y); + + const collapsedEdge = -largeTitleHeight.value; + + // regular scroll + if (currentPosition.value < collapsedEdge && nextPosition < collapsedEdge) { + if (isFlingEmulated(ctx)) { + return; + } + currentPosition.value = nextPosition; + trackVelocity(diff, ctx as any); + return; + } + + /** + * (savelichalex): + * I don't quite understand what is going on + * with Android at this point but the thing is + * that `scrollTo` is actually isn't required for Android here. + * Somehow it manages to adjust scroll position itself + * taking `tranlateY` into account. + * BUT even though it works fine, the scroll view + * is jigerring during scroll. + * + * It looks better with `scrollTo`, but it also has some caveats: + * - header translate looks not that smooth (OK, but not great) + * - we get wrong `contentOffset.y` with event: + * I guess it's because of the same thing that I described above. + * Android tries to compensate transform, and therefore scroll + * happen with different velocity than regular one. + * Applying the hypothesis above I decided to just double the diff, + * and it seems that it does the trick. + * + * Stick to the solution with `scrollTo` for now. + */ + if (diff < 0) { + /** + * 1 here is to trick OverScrollView + * the algorithm is the following: + * https://github.com/Mixiaoxiao/OverScroll-Everywhere/blob/master/OverScroll/src/com/mixiaoxiao/overscroll/OverScrollDelegate.java#L360-L368 + * + * Basically it tries to understand, is there a room to scroll up and down + * so if we set y to 0 the lib would think that it needs to apply + * overscroll animation, but in reality we don't want it here + */ + // Compensate 1 described above + currentPosition.value = nextPosition + diff; + // ctx.skipOnNextScroll = true; + // scrollTo(scrollRef, 0, -diff, false); + // currentPosition.value = nextPosition; + scrollTo(scrollRef, 0, 0, false); + + trackVelocity(diff, ctx as any); + return; + } + currentPosition.value = nextPosition + diff; + // currentPosition.value = nextPosition; + // scrollTo(scrollRef, 0, 0, false); + trackVelocity(diff, ctx as any); + }; +} + +/** + * --------------------------- + * | | | + * | | ----- | ----- + * ----- | ----- | | + * | | ----- | + * | ----- | | ----- + * ----- | | | + * + * + * | | + * | | ----- | + * ----- | ----- | | + * | | | + * | ----- | ----- | + * ----- | | | + * + * + * + * + * + * + * + * + * + * + * + * ----------------------------- + */ + // eslint-disable-next-line func-names -export default function ( +function _old( scrollRef: React.RefObject, largeTitleViewRef: React.RefObject, largeTitleHeight: Animated.SharedValue, @@ -58,7 +318,12 @@ export default function ( return; } - yIsNegative.value = y <= 0; + // yIsNegative.value = y <= 0; + + // TODO: probably unneeded + if (ctx.yWithoutRubberBand == null) { + ctx.yWithoutRubberBand = 0; + } if (parentScrollHandlerActive) { if ( @@ -80,7 +345,8 @@ export default function ( } if (y !== 0) { if (ctx != null) { - ctx.lastApproximateVelocity = y; + ctx.lastApproximateVelocity = y - ctx.lastKnownContentOffsetY; + ctx.lastKnownContentOffsetY = y; } } else { /** @@ -108,7 +374,8 @@ export default function ( // scrollTo reset real y, so we need to count it ourselves ctx.yWithoutRubberBand -= y; shift.value = Math.max(shift.value - y, 0 - largeTitleHeight.value); - scrollTo(scrollRef, 0, 0, false); + + scrollTo(scrollRef, 0, 1, false); } }; } diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/index.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/index.ts deleted file mode 100644 index 51daa4279..000000000 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useScrollFallbackGestureHandler'; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.android.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.android.tsx deleted file mode 100644 index 3e95db997..000000000 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.android.tsx +++ /dev/null @@ -1,109 +0,0 @@ -/* eslint-disable no-param-reassign */ -import * as React from 'react'; - -import type { NativeScrollEvent } from 'react-native'; -import type { PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -import Animated, { useAnimatedGestureHandler } from 'react-native-reanimated'; -import { getWorkletFromParentHandler, ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; - -type ScrollFallbackCtx = { - yPrev: number; -}; - -function createOnActive( - hasScrollShared: Animated.SharedValue, - yIsNegative: Animated.SharedValue, - onScroll: (event: NativeScrollEvent) => void, -) { - return (event: PanGestureHandlerGestureEvent['nativeEvent'], ctx: ScrollFallbackCtx) => { - 'worklet'; - - const y = ctx.yPrev - event.translationY; - ctx.yPrev = event.translationY; - - if (!hasScrollShared.value) { - // eventName is needed to work properly with useEvent - // https://github.com/software-mansion/react-native-reanimated/blob/0c2f66f9855a26efe24f52ecff927fe847f7a80e/src/reanimated2/Hooks.ts#L836 - // @ts-ignore - onScroll({ contentOffset: { y }, eventName: 'onScroll' }); - return; - } - - if (yIsNegative.value && y < 0) { - // eventName is needed to work properly with useEvent - // https://github.com/software-mansion/react-native-reanimated/blob/0c2f66f9855a26efe24f52ecff927fe847f7a80e/src/reanimated2/Hooks.ts#L836 - // @ts-ignore - onScroll({ contentOffset: { y }, eventName: 'onScroll' }); - } - }; -} - -function createOnStart( - hasScrollShared: Animated.SharedValue, - yIsNegative: Animated.SharedValue, - onStartDrag: (event: NativeScrollEvent) => void, -) { - return () => { - 'worklet'; - - if (!hasScrollShared.value) { - // @ts-ignore - onStartDrag({ eventName: 'onScrollBeginDrag' }); - return; - } - if (yIsNegative.value) { - // @ts-ignore - onStartDrag({ eventName: 'onScrollBeginDrag' }); - } - }; -} - -function createOnEnd( - hasScrollShared: Animated.SharedValue, - onEndDrag: (event: NativeScrollEvent) => void, -) { - return (event: PanGestureHandlerGestureEvent['nativeEvent'], ctx: ScrollFallbackCtx) => { - 'worklet'; - - const y = ctx.yPrev - event.translationY; - ctx.yPrev = event.translationY; - - if (!hasScrollShared.value) { - onEndDrag({ - contentOffset: { x: 0, y }, - velocity: { x: event.velocityX, y: event.velocityY }, - // @ts-ignore - eventName: 'onScrollEndDrag', - }); - } - ctx.yPrev = 0; - }; -} - -export function useScrollFallbackGestureHandler( - hasScrollShared: Animated.SharedValue, - yIsNegative: Animated.SharedValue, - scrollHandler: ScrollableParentScrollHandler, -) { - const onActiveRef = React.useRef>(); - const onStartRef = React.useRef>(); - const onEndRef = React.useRef>(); - - const scrollWorklet = getWorkletFromParentHandler(scrollHandler); - - if (onActiveRef.current == null) { - onActiveRef.current = createOnActive(hasScrollShared, yIsNegative, scrollWorklet); - } - if (onStartRef.current == null) { - onStartRef.current = createOnStart(hasScrollShared, yIsNegative, scrollWorklet); - } - if (onEndRef.current == null) { - onEndRef.current = createOnEnd(hasScrollShared, scrollWorklet); - } - - return useAnimatedGestureHandler({ - onActive: onActiveRef.current, - onStart: onStartRef.current, - onEnd: onEndRef.current, - }); -} diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.tsx deleted file mode 100644 index 943bdbc33..000000000 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* eslint-disable import/no-duplicates */ -import type Animated from 'react-native-reanimated'; -import type { useAnimatedGestureHandler } from 'react-native-reanimated'; -import type { ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; - -/** - * On Android ScrollView stops to fire events when it reaches the end (y is 0). - * For that reason we place a ScrollView inside of PanResponder, - * and listen for that events too. - * - * In a regular case we just handle events from scroll. - * But when we see that `y` point is 0 or less, we set a `yIsNegative` guard to true. - * That tells GH handler to start handle events from a pan gesture. - * And that is how we are able to animate large header on overscroll. - * - * Is doesn't return anything for iOS and web to not create unnecessary objects in memory - */ -export function useScrollFallbackGestureHandler( - _hasScrollShared: Animated.SharedValue, - _yIsNegative: Animated.SharedValue, - _scrollHandler: ScrollableParentScrollHandler, -): ReturnType | undefined { - return undefined; -} diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index 4dadee113..ca5025f34 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -34,8 +34,12 @@ "src/" ], "dependencies": { - "nanoid": "^3.1.23", - "react-native-safe-area-context": "^3.1.3" + "@tonlabs/uikit.themes": "^3.0.0", + "@tonlabs/uikit.controls": "^3.0.0", + "@tonlabs/uikit.media": "^3.0.0", + "react-freeze": "^1.0.0", + "react-native-safe-area-context": "^3.1.3", + "nanoid": "^3.1.23" }, "devDependencies": { "@react-native-community/bob": "0.16.2", @@ -52,6 +56,7 @@ "react-native-reanimated": "2.2.1", "react-native-redash": "^16.0.11", "react-native-screens": "3.6.0", + "react-native-simple-shadow-view": "1.6.3", "react-native-svg": "^12.1.1", "typescript": "4.4.3" }, @@ -59,6 +64,8 @@ "@react-navigation/core": "^5.14.4", "@react-navigation/native": "^5.6.1", "@react-navigation/stack": "^5.6.2", + "lottie-ios": "^3.2.3", + "lottie-react-native": "^4.1.3", "react": "^17.0.2", "react-dom": "^17.0.2", "react-native": "^0.65.0", @@ -67,6 +74,7 @@ "react-native-reanimated": "^2.2.1", "react-native-redash": "^16.0.11", "react-native-screens": "^3.6.0", + "react-native-simple-shadow-view": "1.6.3", "react-native-svg": "^12.1.1" }, "@react-native-community/bob": { diff --git a/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx b/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx new file mode 100644 index 000000000..1069cb499 --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import Animated, { interpolate, interpolateColor, useAnimatedStyle } from 'react-native-reanimated'; +import { StyleProp, StyleSheet, ViewStyle } from 'react-native'; + +import { useColorParts, ColorVariants, useTheme } from '@tonlabs/uikit.themes'; + +// @inline +const ANIMATED_ICON_INACTIVE = 0; +// @inline +const ANIMATED_ICON_ACTIVE = 1; +// @inline +const centerDotColorParts = '150,196,228'; + +type MainAnimatedIconProps = { + progress: Animated.SharedValue; + style?: StyleProp; +}; + +export function MainAnimatedIcon({ progress, style }: MainAnimatedIconProps) { + const { colorParts: bgColorParts } = useColorParts(ColorVariants.BackgroundAccent); + // const { colorParts: borderColorParts } = useColorParts( + // ColorVariants.BackgroundTertiaryInverted, + // ); + const theme = useTheme(); + const circle1 = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${bgColorParts}, 0)`, `rgba(${bgColorParts}, 1)`], + ), + borderWidth: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [4, 0], + ), + // borderColor: interpolateColor( + // progress.value, + // [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + // // [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 0)`], + // [`rgb(${borderColorParts})`, `rgb(${bgColorParts})`], + // ), + transform: [ + { + scale: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + // [0.5, 1.5], + [0.5, 1], + ), + }, + ], + }; + }); + const circle2 = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${centerDotColorParts}, 0)`, `rgba(${centerDotColorParts}, 1)`], + ), + borderWidth: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [2, 0], + ), + // borderColor: interpolateColor( + // progress.value, + // [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + // [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 1)`], + // // [`rgba(${borderColorParts}, 1)`, `rgba(${centerDotColorParts}, 1)`], + // ), + transform: [ + { + scale: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [1, 0.4], + // [1, 0.4], + ), + }, + ], + }; + }); + return ( + + + + + ); +} diff --git a/casts/splitNavigator/src/SplitNavigator/ResourceSavingScene.tsx b/casts/splitNavigator/src/SplitNavigator/ResourceSavingScene.tsx index 79b421f86..cfa431095 100644 --- a/casts/splitNavigator/src/SplitNavigator/ResourceSavingScene.tsx +++ b/casts/splitNavigator/src/SplitNavigator/ResourceSavingScene.tsx @@ -24,6 +24,8 @@ export const ResourceSavingScene = ({ isVisible, children, style }: Props) => { Platform.OS === 'ios' ? !isVisible : true } pointerEvents={isVisible ? 'auto' : 'none'} + accessibilityElementsHidden={!isVisible} + importantForAccessibility={isVisible ? 'auto' : 'no-hide-descendants'} > {children} diff --git a/casts/splitNavigator/src/SplitNavigator/ShadowView.tsx b/casts/splitNavigator/src/SplitNavigator/ShadowView.tsx new file mode 100644 index 000000000..b97af7c7d --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/ShadowView.tsx @@ -0,0 +1,2 @@ +// @ts-ignore +export { default as ShadowView } from 'react-native-simple-shadow-view'; diff --git a/casts/splitNavigator/src/SplitNavigator/ShadowView.web.tsx b/casts/splitNavigator/src/SplitNavigator/ShadowView.web.tsx new file mode 100644 index 000000000..27e1adad6 --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/ShadowView.web.tsx @@ -0,0 +1,3 @@ +import { View } from 'react-native'; + +export const ShadowView = View; diff --git a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx new file mode 100644 index 000000000..890b05f0f --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx @@ -0,0 +1,305 @@ +/* eslint-disable react/no-unused-prop-types */ +import * as React from 'react'; +import { View, ImageSourcePropType, StyleProp, ViewStyle, StyleSheet } from 'react-native'; +import { + GestureEvent, + NativeViewGestureHandlerPayload, + NativeViewGestureHandlerProps, + RawButton as GHRawButton, + RawButtonProps, +} from 'react-native-gesture-handler'; +import ReAnimated, { + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { UIImage } from '@tonlabs/uikit.media'; +import { useTheme, ColorVariants } from '@tonlabs/uikit.themes'; +import { hapticSelection } from '@tonlabs/uikit.controls'; +import { ShadowView } from './ShadowView'; + +type SplitScreenTabBarAnimatedIconComponent = React.ComponentType<{ + progress: ReAnimated.SharedValue; + style?: StyleProp; +}>; + +export type SplitScreenTabBarIconOptions = + | { + tabBarActiveIcon: ImageSourcePropType; + tabBarDisabledIcon: ImageSourcePropType; + } + | { + tabBarAnimatedIcon: SplitScreenTabBarAnimatedIconComponent; + }; + +// @inline +const ANIMATED_ICON_INACTIVE = 0; +// @inline +const ANIMATED_ICON_ACTIVE = 1; +const TAB_BAR_ICON_SIZE = 22; + +type AnimatedIconViewProps = { + activeState: boolean; + component: SplitScreenTabBarAnimatedIconComponent; +}; + +function AnimatedIconView({ activeState, component }: AnimatedIconViewProps) { + const progress = useSharedValue(activeState ? ANIMATED_ICON_ACTIVE : ANIMATED_ICON_INACTIVE); + + React.useEffect(() => { + progress.value = withSpring(activeState ? ANIMATED_ICON_ACTIVE : ANIMATED_ICON_INACTIVE, { + overshootClamping: true, + }); + }, [activeState, progress]); + + const Comp = component; + + return ; +} + +type ImageIconViewProps = { + activeState: boolean; + activeSource: ImageSourcePropType; + disabledSource: ImageSourcePropType; +}; +function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconViewProps) { + return ; +} + +export const RawButton: React.FunctionComponent< + ReAnimated.AnimateProps< + RawButtonProps & + NativeViewGestureHandlerProps & { + testID?: string; + style?: StyleProp; + } + > +> = ReAnimated.createAnimatedComponent(GHRawButton); + +function SplitBottomTabBarItem({ + children, + keyProp, + onPress, +}: { + children: React.ReactNode; + // key is reserved prop in React, + // therefore had to call it this way + keyProp: string; + onPress: (key: string) => void; +}) { + const gestureHandler = useAnimatedGestureHandler>( + { + onFinish: () => { + hapticSelection(); + runOnJS(onPress)(keyProp); + }, + }, + ); + return ( + + {children} + + ); +} + +const TAB_BAR_DOT_SIZE = 4; +const TAB_BAR_DOT_BOTTOM = 8; + +type SplitBottomTabBarDotRef = { moveTo(index: number): void }; +type SplitBottomTabBarDotProps = { initialIndex: number }; +const SplitBottomTabBarDot = React.memo( + React.forwardRef( + function SplitBottomTabBarDot({ initialIndex }: SplitBottomTabBarDotProps, ref) { + const theme = useTheme(); + const position = useSharedValue( + (initialIndex + 1) * TAB_BAR_HEIGHT - TAB_BAR_HEIGHT / 2 - TAB_BAR_DOT_SIZE / 2, + ); + React.useImperativeHandle(ref, () => ({ + moveTo(index: number) { + position.value = withSpring( + (index + 1) * TAB_BAR_HEIGHT - TAB_BAR_HEIGHT / 2 - TAB_BAR_DOT_SIZE / 2, + { + overshootClamping: true, + }, + ); + }, + })); + + const style = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: position.value, + }, + ], + }; + }); + return ( + + ); + }, + ), +); + +export function SplitBottomTabBar({ + icons, + activeKey, + onPress, +}: { + icons: Record; + activeKey: string; + onPress: (key: string) => void; +}) { + const theme = useTheme(); + const insets = useSafeAreaInsets(); + + const dotRef = React.useRef(null); + const prevActiveKey = React.useRef(activeKey); + const initialDotIndex = React.useRef(-1); + + const prevIconsRef = React.useRef(); + const iconsMapRef = React.useRef>(); + if (prevIconsRef.current !== icons || iconsMapRef.current == null) { + prevIconsRef.current = icons; + iconsMapRef.current = Object.keys(icons).reduce>((acc, key, i) => { + acc[key] = i; + return acc; + }, {}); + } + const iconsMap = iconsMapRef.current; + + if (initialDotIndex.current === -1 && iconsMap[activeKey] != null) { + initialDotIndex.current = iconsMap[activeKey]; + } + + React.useEffect(() => { + if (activeKey === prevActiveKey.current) { + return; + } + + prevActiveKey.current = activeKey; + + const index = iconsMap[activeKey]; + console.log(index); + if (index != null) { + dotRef.current?.moveTo(index); + } + }, [activeKey, icons, iconsMap]); + + const hasIconForActiveKey = React.useMemo(() => { + return iconsMap[activeKey] != null; + }, [activeKey, iconsMap]); + + /** + * Do not show tab bar when there're only + * 0 or 1 icon available, as it won't do anything useful anyway + */ + if (Object.keys(icons).length < 2) { + return null; + } + + return ( + + + {Object.keys(icons).map(key => { + const icon = icons[key]; + if ('tabBarAnimatedIcon' in icon) { + return ( + + + + ); + } + return ( + + + + ); + })} + {hasIconForActiveKey && ( + + )} + + + ); +} + +const TAB_BAR_DEFAULT_BOTTOM_INSET = 32; +const TAB_BAR_HEIGHT = 64; +export function useTabBarHeight() { + const insets = useSafeAreaInsets(); + return React.useMemo( + () => Math.max(insets.bottom, TAB_BAR_DEFAULT_BOTTOM_INSET) + TAB_BAR_HEIGHT, + [insets.bottom], + ); +} + +const styles = StyleSheet.create({ + icon: { width: TAB_BAR_ICON_SIZE, height: TAB_BAR_ICON_SIZE }, + iconButton: { + height: TAB_BAR_HEIGHT, + width: TAB_BAR_HEIGHT, + alignItems: 'center', + justifyContent: 'center', + }, + dot: { + position: 'absolute', + bottom: TAB_BAR_DOT_BOTTOM, + width: TAB_BAR_DOT_SIZE, + height: TAB_BAR_DOT_SIZE, + borderRadius: TAB_BAR_DOT_SIZE / 2, + }, + container: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + alignItems: 'center', + }, + iconsBox: { + position: 'relative', + flexDirection: 'row', + borderRadius: TAB_BAR_HEIGHT / 2, + shadowRadius: 48, + shadowOffset: { + width: 0, + height: 16, + }, + shadowOpacity: 0.08, + }, +}); diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 652fa62ba..24e8da002 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -1,6 +1,8 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable max-classes-per-file */ /* eslint-disable no-param-reassign */ import { nanoid } from 'nanoid/non-secure'; -import { BaseRouter, StackRouter, TabRouter, StackActions } from '@react-navigation/native'; +import { BaseRouter, StackRouter } from '@react-navigation/native'; import type { DefaultRouterOptions } from '@react-navigation/native'; import type { CommonNavigationAction, @@ -9,8 +11,7 @@ import type { PartialState, Route, Router, - StackNavigationState, - TabNavigationState, + RouterConfigOptions, } from '@react-navigation/core'; type SplitActionType = @@ -49,246 +50,680 @@ export type SplitActionHelpers = { export const MAIN_SCREEN_NAME = 'main'; -type NavigationRoute = Route< - Extract, - ParamList[RouteName] -> & { +type SplitRouterCustomOptions = { + isSplitted: boolean; + tabRouteNames: string[]; + stackRouteNames: string[]; +}; +export type SplitRouterOptions = + DefaultRouterOptions> & SplitRouterCustomOptions; + +export type NavigationRoute< + ParamList extends ParamListBase, + RouteName extends keyof ParamList, +> = Route, ParamList[RouteName]> & { state?: NavigationState | PartialState; - order: number; }; -export type SplitRouterOptions = - DefaultRouterOptions> & { - isSplitted: boolean; +/** + * Before digging into router let me tell you about the state. + * Actually the structure if the state has some limitation + * due to what `react-navigation` expects from router + * in order to work correct. + * Need to remember that out of the box `react-navigation` + * works with three integrated routers: + * - StackRouter + * - TabRouter + * - DrawerRouter + * + * All things in the lib are built around what that routers + * can return. + * + * First thing to notice if a key. The key should be persistent across + * state changes, unless you want to re-render the whole thing. + * Keep that in mind. + * + * + * Second is index and history. + * Those two things are very important for proper linking. + * + * If you don't know what linking is please read the doc: + * https://reactnavigation.org/docs/configuring-links + * TLDR: it's needed to support web urls and deeplinks in mobile. + * + * Index shows to the linking system what route is currently active, + * and the system set is as a route. i.e. if you have following state + * { + * index: 0 + * routes: [{ name: foo }] + * } + * an url will be `/foo` + */ +export type SplitNavigationState = { + /** + * Unique key for the navigation state. + */ + key: string; + /** + * Index of the currently focused route. + * + * We change this index even for nested stack, + * to make linking work properly + */ + index: number; + /** + * Index of the currently focused tab route. + * Since we can't use `index` only for tabs + */ + tabIndex: number; + /** + * List of valid route names as defined in the screen components. + */ + routeNames: string[]; + /** + * Whether the navigation state has been rehydrated. + */ + stale: false; + /** + * Custom type for the state, whether it's for split, stack etc. + * During rehydration, the state will be discarded if type doesn't match with router type. + * It can also be used to detect the type of the navigator we're dealing with. + */ + type: 'split'; + /** + * List of previously visited route indexes. + */ + history: number[]; + /** + * Routes to use in stack + */ + nestedStack?: number[]; + /** + * List of rendered routes. + */ + routes: NavigationRoute[]; + /** + * Whether it's splitted now or not + */ + isSplitted: boolean; +}; + +function fold( + state: SplitNavigationState, + stackRouteNames: string[], +): SplitNavigationState { + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + const nestedStack = [mainRouteIndex]; + let tabIndex = state.index; + if (stackRouteNames.includes(state.routes[state.index].name)) { + tabIndex = mainRouteIndex; + nestedStack.push(state.index); + } + + return { + ...state, + key: `split-${nanoid()}`, + tabIndex, + nestedStack, + isSplitted: false, + }; +} + +function unfold(state: SplitNavigationState, initialRouteName?: string): SplitNavigationState { + let { index, tabIndex } = state; + const { history } = state; + if (state.routes[state.index].name === MAIN_SCREEN_NAME) { + if (initialRouteName && state.routeNames.includes(initialRouteName)) { + index = state.routeNames.indexOf(initialRouteName); + tabIndex = index; + history.push(index); + } + } + return { + ...state, + key: `split-${nanoid()}`, + index, + tabIndex, + history, + isSplitted: true, }; +} + +function applyTabNavigateActionToRoutes( + state: SplitNavigationState, + action: CommonNavigationAction, + options: RouterConfigOptions, + index: number, +): SplitNavigationState['routes'] { + if (action.type !== 'NAVIGATE') { + return state.routes; + } + return action.payload.params == null + ? state.routes + : state.routes.map((route, i) => { + if (i === index) { + return route; + } + + /** + * Next code up to return is simply copy-pasted + * from TabRouter to support a merge param + * https://github.com/react-navigation/react-navigation/blob/%40react-navigation/core%405.16.1/packages/routers/src/TabRouter.tsx#L317-L341 + */ + let newParams: any; + + if (action.type === 'NAVIGATE' && action.payload.merge === false) { + newParams = + options.routeParamList[route.name] !== undefined + ? { + ...options.routeParamList[route.name], + ...action.payload.params, + } + : action.payload.params; + } else { + newParams = action.payload.params + ? { + ...route.params, + ...action.payload.params, + } + : route.params; + } + + return newParams !== route.params ? { ...route, params: newParams } : route; + }); +} -const stackStateToTab = ( - state: StackLikeSplitNavigationState, - options: SplitRouterOptions, -): TabLikeSplitNavigationState => { - let { index } = state; - - const currentRoute = state.routes[index] as NavigationRoute; - if (currentRoute.name !== MAIN_SCREEN_NAME) { - index = state.routeNames.indexOf(currentRoute.name); - } else if (options.initialRouteName != null) { - index = state.routeNames.indexOf(options.initialRouteName); +class SplitUnfoldedRouter { + options: SplitRouterOptions; + + constructor(options: SplitRouterOptions) { + this.options = options; } - const routes = state.routeNames.map(name => { - const route = state.routes.find(({ name: routeName }) => routeName === name); + /* + * When we in splitted state all routes except `main` + * are treated as tabs (They're rendered on the right side). + * `main` is kind of special, as it renders on the left column. + */ + getInitialState({ routeNames, routeParamList }: RouterConfigOptions): SplitNavigationState { + const { initialRouteName, isSplitted } = this.options; + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = mainRouteIndex; + const history = [mainRouteIndex]; + if (initialRouteName != null && routeNames.includes(initialRouteName)) { + index = routeNames.indexOf(initialRouteName); + history[0] = index; + } - if (route) { + return { + key: `split-${nanoid()}`, + index, + tabIndex: index, + routeNames, + stale: false, + type: 'split', + isSplitted, + history, + routes: routeNames.map(name => { + if (name === MAIN_SCREEN_NAME) { + return { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: routeParamList[MAIN_SCREEN_NAME], + }; + } + return { + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + }; + }), + }; + } + + getRehydratedState( + partialState: SplitNavigationState | PartialState, + { routeNames, routeParamList }: RouterConfigOptions, + ): SplitNavigationState { + const { initialRouteName, isSplitted } = this.options; + + const routes = routeNames.map(name => { + // @ts-ignore + const route = partialState.routes?.find(r => r.name === name) ?? null; return { ...route, - // change a route key, to force re-render of the screen - // to avoid race-conditions in nested navigation - key: `${route.name}-${nanoid()}`, + name, + key: route && route.name === name && route.key ? route.key : `${name}-${nanoid()}`, + params: + routeParamList[name] != null + ? { ...routeParamList[name], ...route?.params } + : route?.params, }; + }); + + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = mainRouteIndex; + const history = [mainRouteIndex]; + const activeRouteIndex = partialState?.index ?? 0; + const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; + if (activeRouteName != null) { + if (routeNames.includes(activeRouteName)) { + // set an index for a regular tab route + index = activeRouteIndex; + history[0] = activeRouteIndex; + } else if (initialRouteName != null) { + index = routeNames.indexOf(initialRouteName); + history[0] = index; + } } + return { - name, - key: `${name}-${nanoid()}`, + key: `split-${nanoid()}`, + index, + tabIndex: index, + routeNames, + stale: false, + type: 'split', + isSplitted, + history, + routes, }; - }) as TabLikeSplitNavigationState['routes']; + } - return { - ...state, - index, - routes, - history: [currentRoute], - }; -}; + getStateForRouteFocus(state: SplitNavigationState, key: string): SplitNavigationState { + const index = state.routes.findIndex(r => r.key === key); -const tabStateToStack = ( - state: TabLikeSplitNavigationState, -): StackLikeSplitNavigationState => { - let { index } = state; - const possibleMainRoute = state.routes.find(({ name }) => name === MAIN_SCREEN_NAME); - let mainRoute: NavigationRoute; - - if (possibleMainRoute) { - mainRoute = { - ...possibleMainRoute, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - } as NavigationRoute; - } else { - mainRoute = { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - } as NavigationRoute; + if (index === -1 || index === state.index) { + return state; + } + + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + }; } - let routes; - const currentRoute = { - ...state.routes[index], - // change a route key, to force re-render of the screen - // to avoid race-conditions in nested navigation - key: `${state.routes[index].name}-${nanoid()}`, - }; - if (currentRoute.name === MAIN_SCREEN_NAME) { - index = 0; - routes = [mainRoute]; - } else { - index = 1; - routes = [mainRoute, currentRoute]; + + getStateForAction( + state: SplitNavigationState, + action: CommonNavigationAction | SplitActionType, + options: RouterConfigOptions, + ): SplitNavigationState | null { + if (action.type === 'RESET_TO_INITIAL') { + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + return { + ...state, + tabIndex: mainRouteIndex, + history: [mainRouteIndex], + }; + } + if (action.type === 'GO_BACK') { + if (state.history.length < 2) { + return null; + } + const prevRouteIndex = state.history[state.history.length - 2]; + return { + ...state, + index: prevRouteIndex, + tabIndex: prevRouteIndex, + history: state.history.filter(r => r !== prevRouteIndex).concat([prevRouteIndex]), + routes: applyTabNavigateActionToRoutes(state, action, options, prevRouteIndex), + }; + } + if (action.type === 'NAVIGATE') { + const { key } = action.payload; + let { name } = action.payload; + if (key != null) { + const route = state.routes.find(r => r.key === key); + name = route?.name; + } + if (name == null) { + return null; + } + const index = state.routeNames.indexOf(name); + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + routes: applyTabNavigateActionToRoutes(state, action, options, index), + }; + } + return null; } - return { - ...state, - index, - routes, - }; -}; +} -type StackLikeSplitNavigationState = Omit< - StackNavigationState, - 'type' | 'history' -> & { - type: 'split'; - history?: ( - | NavigationRoute - | { - type: string; - key: string; - } - )[]; -}; +class SplitFoldedRouter { + options: SplitRouterOptions; -type TabLikeSplitNavigationState = Omit< - TabNavigationState, - 'type' | 'history' -> & { - type: 'split'; - history?: ( - | NavigationRoute - | { - type: string; - key: string; - } - )[]; -}; + stackRouter: ReturnType; -export type SplitNavigationState = ( - | StackLikeSplitNavigationState - | TabLikeSplitNavigationState -) & { isSplitted?: boolean }; + constructor(options: SplitRouterOptions) { + this.options = options; + this.stackRouter = StackRouter({}); + } -export function SplitRouter(routerOptions: SplitRouterOptions) { - // eslint-disable-next-line prefer-const - let { isSplitted, initialRouteName, ...tabOptions } = routerOptions; - const { ...stackOptions } = tabOptions; - const tabRouter = TabRouter({ - ...tabOptions, - initialRouteName, - }); - const stackRouter = StackRouter({ - ...stackOptions, - initialRouteName: MAIN_SCREEN_NAME, - }); - let isInitialized = false; - const router: Router & { - ensureTabState(state: SplitNavigationState): SplitNavigationState; - ensureStackState(state: SplitNavigationState): SplitNavigationState; - } = { - ...BaseRouter, + /** + * We have two types of routes in folded mode: + * - Routes that have to be on tabs section, + * they have icon in the tab bar, + * and are toggled with fade in/out animation; + * - Routes that have to be in stack navigation. + * It's the ones that haven't found a place + * in tab bar, therefore have to be animated + * from main screen with stack navigation. + */ + getInitialState({ routeNames, routeParamList }: RouterConfigOptions): SplitNavigationState { + const { initialRouteName, stackRouteNames, isSplitted } = this.options; + /** + * A challenge here is to find index for initialRouteName - + * it's actually can fall on different states: + * - If the route is for tab, then we set `index` for root state; + * - If the route is for stack, then in root state `index` is for main. + * + * TODO: discuss behaviour, as for now it might work weird, as a tab for `assets` + * going to be initial, when in reality it should be `main`, however in splitted mode + * it's a correct behaviour + */ + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = mainRouteIndex; + let tabIndex = mainRouteIndex; + const history = [mainRouteIndex]; + const nestedStack = [mainRouteIndex]; + if (initialRouteName != null && routeNames.includes(initialRouteName)) { + if (stackRouteNames.includes(initialRouteName)) { + // It's a route for nested stack + const nestedStackRouteNameIndex = routeNames.indexOf(initialRouteName); + index = nestedStackRouteNameIndex; + // tab index is already points to the main + nestedStack.push(nestedStackRouteNameIndex); + history.push(nestedStackRouteNameIndex); + } else { + // It's a route for tab navigation + index = routeNames.indexOf(initialRouteName); + tabIndex = index; + history[0] = index; + } + } - // Every router in react-navigation should have a type - // and it should be consistent between re-renders - // or library will try to re-initialize the state - // with every re-render - type: 'split', + return { + key: `split-${nanoid()}`, + index, + tabIndex, + routeNames, + stale: false, + type: 'split', + isSplitted, + history, + nestedStack, + routes: routeNames.map(name => { + return { + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + }; + }), + }; + } - ensureTabState(newState: SplitNavigationState) { - // Move from "main" route in splitted version - const currentRouteName = newState.routeNames[newState.index]; - if (currentRouteName === MAIN_SCREEN_NAME) { - if (initialRouteName != null) { - // @ts-ignore index is read-only in type declaration - newState.index = newState.routeNames.indexOf(initialRouteName); + getRehydratedState( + partialState: SplitNavigationState | PartialState, + { routeNames, routeParamList }: RouterConfigOptions, + ): SplitNavigationState { + const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; + + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = mainRouteIndex; + let tabIndex = mainRouteIndex; + const history = [mainRouteIndex]; + const nestedStack = [mainRouteIndex]; + const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; + if (activeRouteName != null) { + if (tabRouteNames.includes(activeRouteName)) { + // set an index for a regular tab route + index = routeNames.indexOf(activeRouteName); + tabIndex = index; + history[0] = index; + } else if (stackRouteNames.includes(activeRouteName)) { + // leave it as a main and then set index for nested stack + const nestedStackRouteNameIndex = routeNames.indexOf(activeRouteName); + index = nestedStackRouteNameIndex; + // tab index is already points to the main + nestedStack.push(nestedStackRouteNameIndex); + history.push(nestedStackRouteNameIndex); + } else if (initialRouteName) { + // nothing was found in known routes + // tring to find initial route + // + // (savelichalex): Maybe it's a good place to redirect to 404 + if (stackRouteNames.includes(initialRouteName)) { + // It's a route for nested stack + const nestedStackRouteNameIndex = routeNames.indexOf(initialRouteName); + index = nestedStackRouteNameIndex; + // tab index is already points to the main + nestedStack.push(nestedStackRouteNameIndex); + history.push(nestedStackRouteNameIndex); } else { - // @ts-ignore index is read-only in type declaration - newState.index += 1; + // It's a route for tab navigation + index = routeNames.indexOf(initialRouteName); + tabIndex = index; + history[0] = index; } } + } - return newState; - }, + return { + key: `split-${nanoid()}`, + index, + tabIndex, + routeNames, + stale: false, + type: 'split', + isSplitted, + history, + nestedStack, + routes: routeNames.map(name => { + // @ts-ignore + const route = partialState.routes?.find(r => r.name === name); + return { + ...route, + name, + key: + route && route.name === name && route.key + ? route.key + : `${name}-${nanoid()}`, + params: + routeParamList[name] != null + ? { ...routeParamList[name], ...route?.params } + : route?.params, + }; + }), + }; + } - ensureStackState(newState: SplitNavigationState) { - const mainRoute = newState.routes.find(({ name }) => name === MAIN_SCREEN_NAME) || { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + getStateForRouteFocus(state: SplitNavigationState, key: string): SplitNavigationState { + const index = state.routes.findIndex(r => r.key === key); + + if (index === -1 || index === state.index) { + return state; + } + + const { stackRouteNames } = this.options; + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + const routeToFocusName = state.routes[index].name; + if (stackRouteNames.includes(routeToFocusName)) { + return { + ...state, + index, + tabIndex: mainRouteIndex, + nestedStack: [mainRouteIndex, index], + history: state.history.filter(r => r !== index).concat([index]), }; - const currentRoute = newState.routes[newState.index]; - if (currentRoute.name === MAIN_SCREEN_NAME) { - // @ts-ignore index is read-only in type declaration - newState.index = 0; - // @ts-ignore routes is read-only in type declaration - newState.routes = [mainRoute]; - } else { - // @ts-ignore index is read-only in type declaration - newState.index = 1; - // @ts-ignore routes is read-only in type declaration - newState.routes = [mainRoute, currentRoute]; - } + } - return newState; - }, + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + }; + } - getInitialState(params) { - let newState: SplitNavigationState; + getStateForAction( + state: SplitNavigationState, + action: CommonNavigationAction | SplitActionType, + options: RouterConfigOptions, + ): SplitNavigationState | null { + const { tabRouteNames, stackRouteNames } = this.options; + if (action.type === 'RESET_TO_INITIAL') { + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + if (stackRouteNames.includes(state.routes[state.index].name)) { + return { + ...state, + index: mainRouteIndex, + // tab is already set to main + history: [mainRouteIndex], + nestedStack: [mainRouteIndex], + }; + } + return { + ...state, + tabIndex: mainRouteIndex, + history: [mainRouteIndex], + }; + } + if (action.type === 'GO_BACK') { + // In folded mode that shouldn't be a case + // Suppress TS error + if (state.nestedStack == null) { + return null; + } - if (isSplitted) { - newState = tabRouter.getInitialState(params) as any; - newState = this.ensureTabState(newState); - } else { - newState = stackRouter.getInitialState(params) as any; - newState = this.ensureStackState(newState); + const currentTabRoute = state.routes[state.tabIndex]; + // If it's main, and it has items in stack, try to go_back there + if (currentTabRoute.name === MAIN_SCREEN_NAME && state.nestedStack.length > 0) { + const nestedStack = state.nestedStack.slice(0, state.nestedStack.length - 1); + return { + ...state, + nestedStack, + history: state.history.slice(0, state.history.length - 2), + index: nestedStack[nestedStack.length - 1], + }; + } + // If it isn't main, then do the same thing as in unfolded router + if (state.history.length < 2) { + return null; + } + const prevRouteIndex = state.history[state.history.length - 2]; + return { + ...state, + index: prevRouteIndex, + tabIndex: prevRouteIndex, + history: state.history.filter(r => r !== prevRouteIndex).concat([prevRouteIndex]), + routes: applyTabNavigateActionToRoutes(state, action, options, prevRouteIndex), + }; + } + if (action.type === 'NAVIGATE') { + // In folded mode that shouldn't be a case + // Suppress TS error + if (state.nestedStack == null) { + return null; } - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + const { key } = action.payload; + let { name } = action.payload; + if (key != null) { + const route = state.routes.find(r => r.key === key); + name = route?.name; + } + if (name == null) { + return null; + } + const index = state.routeNames.indexOf(name); + if (tabRouteNames.includes(name)) { + // In shrinked mode need to apply simple tab navigation + // only for routes that are in the bar + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + routes: applyTabNavigateActionToRoutes(state, action, options, index), + }; + } + const mainIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + return { + ...state, + index, + tabIndex: mainIndex, + nestedStack: [mainIndex, index], + history: state.history.filter(r => r !== index).concat([index]), + routes: applyTabNavigateActionToRoutes(state, action, options, mainIndex), + }; + } + return null; + } +} + +export function SplitRouter(routerOptions: SplitRouterOptions) { + // eslint-disable-next-line prefer-const + let { isSplitted } = routerOptions; + let isInitialized = false; + const foldedRouter = new SplitFoldedRouter(routerOptions); + const unfoldedRouter = new SplitUnfoldedRouter(routerOptions); + const router: Router = { + ...BaseRouter, + + // Every router in react-navigation should have a type + // and it should be consistent between re-renders + // or library will try to re-initialize the state + // every time + type: 'split', + + getInitialState(options: RouterConfigOptions) { + return (isSplitted ? unfoldedRouter : foldedRouter).getInitialState(options); }, - getRehydratedState(state, params): SplitNavigationState { + /** + * Usually it's called for linking, as the result of it is a partial state + * i.e. for /foo/bar it's going to be sth like this + * { + * stale: true, + * routes: [{ + * name: 'foo', + * state: { + * stale: true, + * routes: [{ + * name: 'bar' + * }] + * } + * }] + * } + */ + getRehydratedState( + state: SplitNavigationState | PartialState, + options, + ): SplitNavigationState { const isStale = state.stale; - let newState: SplitNavigationState; if (isStale === false) { return state as SplitNavigationState; } - if (isSplitted) { - newState = tabRouter.getRehydratedState(state as any, params) as any; - newState = this.ensureTabState(newState); - } else { - newState = stackRouter.getRehydratedState(state as any, params) as any; - newState = this.ensureStackState(newState); - } - - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + return (isSplitted ? unfoldedRouter : foldedRouter).getRehydratedState(state, options); }, - getStateForRouteNamesChange(state, options) { - const newState: SplitNavigationState = isSplitted - ? (tabRouter.getStateForRouteNamesChange(state as any, options) as any) - : stackRouter.getStateForRouteNamesChange(state as any, options); - - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + getStateForRouteNamesChange(state /* , options */) { + console.warn("Dynamic routes isn't supported yet in SplitRouter"); + return state; }, getStateForRouteFocus(state, key) { - const newState: SplitNavigationState = isSplitted - ? (tabRouter.getStateForRouteFocus(state as any, key) as any) - : stackRouter.getStateForRouteFocus(state as any, key); - - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + return (isSplitted ? unfoldedRouter : foldedRouter).getStateForRouteFocus(state, key); }, getStateForAction(state: SplitNavigationState, action, options) { - let newState: SplitNavigationState = state; if (action.type === 'SET_SPLITTED') { ({ isSplitted } = action.payload); @@ -298,101 +733,30 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { } if (action.payload.initialRouteName) { - ({ initialRouteName } = action.payload); + foldedRouter.options.initialRouteName = action.payload.initialRouteName; + unfoldedRouter.options.initialRouteName = action.payload.initialRouteName; } if (isSplitted) { - newState = stackStateToTab( - state as StackLikeSplitNavigationState, - routerOptions, - ); - } else { - newState = tabStateToStack(state as TabLikeSplitNavigationState); - } - } else if (action.type === 'RESET_TO_INITIAL') { - if (isSplitted) { - const initialRouteIndex = state.routes.findIndex( - ({ name }) => name === initialRouteName, - ); - - if (initialRouteIndex === -1) { - newState = state; - } else { - // Get the initial route - const initialRoute = state.routes[initialRouteIndex]; - - // Recreate the state in order to show the initial route of the navigator - const newRoutes = state.routes.map(route => { - if (route.key === initialRoute.key) { - // Make initial route pop to its initial sub-route as well!!! - if (route.state && route.state.type === 'stack') { - // Get the initial route state from the nested stack navigator - // by popping it to the top to its initial sub-route - const stackState = stackRouter.getStateForAction( - route.state as any, - StackActions.popToTop(), - options, - ); - // If the state presents apply it to the sub-route - if (stackState != null) { - return { - ...route, - state: stackState, - }; - } - } - } - return route; - }); - - // Struct a new state to show the truly initial splitted navigator state - newState = { - ...state, - routes: newRoutes as any, - index: initialRouteIndex, - history: [{ type: 'route', key: initialRoute.key }], - }; - } - } else { - const tempState = stackRouter.getStateForAction( - state as any, - StackActions.popToTop(), - options, + return unfold( + state, + action.payload.initialRouteName || unfoldedRouter.options.initialRouteName, ); - - if (tempState != null) { - newState = tempState as any; - } - } - } else { - newState = isSplitted - ? (tabRouter.getStateForAction(state as any, action, options) as any) - : stackRouter.getStateForAction(state as any, action, options); - - if (newState == null) { - return null; - } - - // Ensure the history always includes the initial route - // N.B. This is mostly important for tab router as it might loose the initial route - const { history } = newState; - if (history) { - // Check if the history contains the initial route already - const initialRoute = state.routes.find(({ name }) => name === initialRouteName); - if (initialRoute && !history.find(({ key }) => key === initialRoute.key)) { - // Add the initial route to the beginning of the history if not - // @ts-ignore `history` is declared as read-only, but we have to overwrite it - newState.history = [{ type: 'route', key: initialRoute.key }, ...history]; - } } + return fold(state, routerOptions.stackRouteNames); } - - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + if (action.type === 'SET_PARAMS' || action.type === 'RESET') { + return BaseRouter.getStateForAction(state, action); + } + return (state.isSplitted ? unfoldedRouter : foldedRouter).getStateForAction( + state, + action, + options, + ); }, shouldActionChangeFocus(action) { - return tabRouter.shouldActionChangeFocus(action); + return action.type === 'NAVIGATE'; }, }; diff --git a/casts/splitNavigator/src/SplitNavigator/TabScreen.tsx b/casts/splitNavigator/src/SplitNavigator/TabScreen.tsx new file mode 100644 index 000000000..7dc3eb41a --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/TabScreen.tsx @@ -0,0 +1,51 @@ +import * as React from 'react'; +import { StyleSheet, ViewStyle, StyleProp } from 'react-native'; +import { Freeze } from 'react-freeze'; +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; + +import { ResourceSavingScene } from './ResourceSavingScene'; + +type TabScreenProps = { + isVisible: boolean; + children: React.ReactNode; + style?: StyleProp; +}; +export function TabScreen({ isVisible, style, children }: TabScreenProps) { + const visible = useSharedValue(false); + const opacity = useSharedValue(0); + + React.useEffect(() => { + if (visible.value === false && isVisible === true) { + opacity.value = withSpring(1, { overshootClamping: true }); + visible.value = true; + return; + } + if (visible.value === true && isVisible === false) { + opacity.value = withSpring(0, { overshootClamping: true }, isFinished => { + if (isFinished) { + visible.value = false; + } + }); + } + }, [isVisible, visible, opacity]); + + const fadeStyle = useAnimatedStyle(() => { + return { + opacity: opacity.value, + }; + }); + + return ( + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 9ff09e77f..553d647d2 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -7,86 +7,458 @@ import { Platform, StyleProp, } from 'react-native'; +import type { Descriptor, EventMapBase, NavigationState } from '@react-navigation/core'; import { NavigationHelpersContext, useNavigationBuilder, createNavigatorFactory, - useTheme, } from '@react-navigation/native'; -import type { StackNavigationState, NavigationProp, ParamListBase } from '@react-navigation/native'; +import type { NavigationProp, ParamListBase } from '@react-navigation/native'; import { screensEnabled, ScreenContainer } from 'react-native-screens'; -import { StackView } from '@react-navigation/stack'; +import { StackView, TransitionPresets } from '@react-navigation/stack'; import { NativeStackView } from 'react-native-screens/native-stack'; import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; -import { SplitRouter, SplitActions, MAIN_SCREEN_NAME, SplitActionHelpers } from './SplitRouter'; +import { + SplitRouter, + SplitActions, + MAIN_SCREEN_NAME, + SplitActionHelpers, + NavigationRoute, +} from './SplitRouter'; import type { SplitNavigationState, SplitRouterOptions } from './SplitRouter'; +import { + SplitBottomTabBar, + SplitScreenTabBarIconOptions, + useTabBarHeight, +} from './SplitBottomTabBar'; +import { MainAnimatedIcon } from './MainAnimatedIcon'; +import { TabScreen } from './TabScreen'; -export const NestedInSplitContext = React.createContext<{ - isSplitted: boolean; -}>({ isSplitted: false }); +export const NestedInSplitContext = React.createContext(false); const getIsSplitted = ({ width }: { width: number }, mainWidth: number) => width > mainWidth; -function SceneContent({ isFocused, children }: { isFocused: boolean; children: React.ReactNode }) { - const { colors } = useTheme(); - - return ( - - {children} - - ); +const SplitTabBarHeightContext = React.createContext(0); +export function useSplitTabBarHeight() { + return React.useContext(SplitTabBarHeightContext); } -type SurfSplitNavigatorProps = { +type SplitStyles = { + body?: StyleProp; + main?: StyleProp; + detail?: StyleProp; +}; +type SplitNavigatorProps = { children?: React.ReactNode; initialRouteName: string; mainWidth: number; screenOptions: { - splitStyles?: { - body?: StyleProp; - main?: StyleProp; - detail?: StyleProp; - }; + splitStyles?: SplitStyles; } & SplitRouterOptions; }; +type SplitScreenOptions = SplitScreenTabBarIconOptions | Record; + +function UnfoldedSplitNavigator({ + navigation, + descriptors, + state, + mainRoute, + splitStyles, + tabRouteNamesMap, + loaded, + onTabPress, +}: { + splitStyles: SplitStyles; + navigation: any; + descriptors: Record< + string, + Descriptor< + // eslint-disable-next-line @typescript-eslint/ban-types + Record, + string, + SplitNavigationState, + SplitScreenOptions, + // eslint-disable-next-line @typescript-eslint/ban-types + {} + > + >; + state: SplitNavigationState; + mainRoute: NavigationRoute; + tabRouteNamesMap: Set; + loaded: number[]; + onTabPress: (key: string) => void; +}) { + const tabBarIcons = React.useMemo( + () => + state.routes.reduce>((acc, route) => { + if (!tabRouteNamesMap.has(route.name)) { + return acc; + } + + if (route.key === mainRoute.key) { + return acc; + } + + const descriptor = descriptors[route.key]; + if (descriptor.options == null) { + return acc; + } + if ('tabBarActiveIcon' in descriptor.options) { + acc[route.key] = { + tabBarActiveIcon: descriptor.options.tabBarActiveIcon, + tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, + }; + return acc; + } + if ('tabBarAnimatedIcon' in descriptor.options) { + acc[route.key] = { + tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, + }; + return acc; + } + + return acc; + }, {}), + // The rule is disabled to not include `descriptors` as a dep + // because descriptors going to be changed every render + // and will ruin the optimisation + // eslint-disable-next-line react-hooks/exhaustive-deps + [tabRouteNamesMap, mainRoute.key, state.routes], + ); + + const doesSupportNative = Platform.OS !== 'web' && screensEnabled?.(); + const tabBarHeight = useTabBarHeight(); + + return ( + + + + + + + {descriptors[mainRoute.key].render()} + + + + + + {state.routes.map((route, index) => { + // Do not render main route + if (route.key === mainRoute.key) { + return null; + } + + const descriptor = descriptors[route.key]; + const isFocused = state.tabIndex === index; + + // isFocused check is important here + // as we can try to render a screen before it was put + // to `loaded` screens + if (!loaded.includes(index) && !isFocused) { + // Don't render a screen if we've never navigated to it + return null; + } + + return ( + + {descriptor.render()} + + ); + })} + + + + + + + ); +} + +function FoldedSplitNavigator({ + navigation, + descriptors, + state, + mainRoute, + tabRouteNames, + tabRouteNamesMap, + stackRouteNames, + loaded, + onTabPress, +}: { + navigation: any; + descriptors: Record< + string, + Descriptor< + // eslint-disable-next-line @typescript-eslint/ban-types + Record, + string, + SplitNavigationState, + SplitScreenOptions, + // eslint-disable-next-line @typescript-eslint/ban-types + {} + > + >; + state: SplitNavigationState; + mainRoute: NavigationRoute; + tabRouteNames: string[]; + tabRouteNamesMap: Set; + stackRouteNames: string[]; + loaded: number[]; + onTabPress: (key: string) => void; +}) { + const tabBarIcons = React.useMemo( + () => + state.routes.reduce>((acc, route) => { + if (!tabRouteNamesMap.has(route.name)) { + return acc; + } + + const descriptor = descriptors[route.key]; + if (descriptor.options == null) { + return acc; + } + if ('tabBarActiveIcon' in descriptor.options) { + acc[route.key] = { + tabBarActiveIcon: descriptor.options.tabBarActiveIcon, + tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, + }; + return acc; + } + if ('tabBarAnimatedIcon' in descriptor.options) { + acc[route.key] = { + tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, + }; + return acc; + } + if (mainRoute.key === route.key) { + acc[route.key] = { + tabBarAnimatedIcon: MainAnimatedIcon, + }; + return acc; + } + + return acc; + }, {}), + // The rule is disabled to not include `descriptors` as a dep + // because descriptors going to be changed every render + // and will ruin the optimisation + // eslint-disable-next-line react-hooks/exhaustive-deps + [tabRouteNamesMap, mainRoute.key, state.routes], + ); + const stackDescriptors = (state.nestedStack ?? []).reduce( + (acc, routeIndex) => { + const route = state.routes[routeIndex]; + const descriptor = descriptors[route.key]; + + if (route.key === mainRoute.key) { + acc[route.key] = { + ...descriptor, + render: () => { + return ( + + {tabRouteNames.map(tabName => { + const tabRouteIndex = state.routeNames.indexOf(tabName); + const tabRoute = state.routes[tabRouteIndex]; + const tabDescriptor = descriptors[tabRoute.key]; + const isFocused = state.tabIndex === tabRouteIndex; + + // isFocused check is important here + // as we can try to render a screen before it was put + // to `loaded` screens + if (!loaded.includes(tabRouteIndex) && !isFocused) { + // Don't render a screen if we've never navigated to it + return null; + } + return ( + + {tabDescriptor.render()} + + ); + })} + + + ); + }, + }; + return acc; + } + acc[route.key] = descriptor; + return acc; + }, + {}, + ); + + const stackState = React.useMemo( + () => ({ + stale: false, + type: 'stack', + key: state.key.replace('split', 'stack'), + index: state.nestedStack ? state.nestedStack.length - 1 : 0, + routeNames: stackRouteNames, + routes: (state.nestedStack ?? []).map(routeIndex => { + return state.routes[routeIndex]; + }), + }), + [stackRouteNames, state.nestedStack, state.routes, state.key], + ); + + const doesSupportNative = Platform.OS !== 'web' && screensEnabled?.(); + const tabBarHeight = useTabBarHeight(); + + if (doesSupportNative) { + return ( + + + + + + + + ); + } + + return ( + + + + {/* @ts-ignore */} + + + + + ); +} -export const SplitNavigator = ({ +export function SplitNavigator({ children, initialRouteName, mainWidth, screenOptions, -}: SurfSplitNavigatorProps) => { +}: SplitNavigatorProps) { const dimensions = useWindowDimensions(); const isSplitted = getIsSplitted(dimensions, mainWidth); - const doesSupportNative = Platform.OS !== 'web' && screensEnabled?.(); const { splitStyles: splitStylesFromOptions, ...restScreenOptions } = screenOptions || {}; - const splitStyles = splitStylesFromOptions || { - body: styles.body, - main: styles.main, - detail: styles.detail, - }; + const splitStyles = splitStylesFromOptions || defaultSplitStyles; + + // A little optimisation to not create it with every render + const prevChildren = React.useRef(null); + const tabRouteNamesRef = React.useRef(); + const tabRouteNamesMapRef = React.useRef>(); + const stackRouteNamesRef = React.useRef(); + + if ( + prevChildren.current !== children || + tabRouteNamesRef.current == null || + tabRouteNamesMapRef.current == null || + stackRouteNamesRef.current == null + ) { + const { tabRouteNames, stackRouteNames } = React.Children.toArray(children).reduce<{ + tabRouteNames: string[]; + stackRouteNames: string[]; + }>( + (acc, child) => { + if (React.isValidElement(child)) { + if ( + child.props.name === MAIN_SCREEN_NAME || + (child.props.options != null && + ('tabBarActiveIcon' in child.props.options || + 'tabBarAnimatedIcon' in child.props.options)) + ) { + acc.tabRouteNames.push(child.props.name); + } else { + acc.stackRouteNames.push(child.props.name); + } + } + return acc; + }, + { + tabRouteNames: [], + stackRouteNames: [], + }, + ); + tabRouteNamesRef.current = tabRouteNames; + tabRouteNamesMapRef.current = new Set(tabRouteNames); + stackRouteNamesRef.current = stackRouteNames; + } + + const tabRouteNames = tabRouteNamesRef.current; + const tabRouteNamesMap = tabRouteNamesMapRef.current; + const stackRouteNames = stackRouteNamesRef.current; + + const doesSupportNative = Platform.OS !== 'web' && screensEnabled?.(); + const { state, navigation, descriptors } = useNavigationBuilder< SplitNavigationState, SplitRouterOptions, SplitActionHelpers, - SplitRouterOptions, + SplitScreenOptions, NavigationProp >(SplitRouter, { children, - initialRouteName, + initialRouteName: isSplitted ? initialRouteName : MAIN_SCREEN_NAME, screenOptions: { ...restScreenOptions, // @ts-ignore it's doesn't exist in our options // but it's needed to turn of header in react-native-screens headerShown: false, + ...(doesSupportNative + ? Platform.select({ + android: { + stackAnimation: 'slide_from_right', + }, + default: null, + }) + : { + ...TransitionPresets.SlideFromRightIOS, + animationEnabled: true, + }), }, + tabRouteNames, + stackRouteNames, isSplitted, }); @@ -106,101 +478,58 @@ export const SplitNavigator = ({ // Access it from the state to re-render a container // only when router has processed SET_SPLITTED action - if (state.isSplitted) { - const mainRoute = state.routes.find( - ({ name }: { name: string }) => name === MAIN_SCREEN_NAME, - ); - if (mainRoute == null) { - throw new Error(`You should provide ${MAIN_SCREEN_NAME} screen!`); - } - return ( - - - - - - {descriptors[mainRoute.key].render()} - - - - {state.routes.map((route, index) => { - const descriptor = descriptors[route.key]; - const isFocused = state.index === index; - - // Do not render main route - if (route.key === mainRoute.key) { - return null; - } - - // isFocused check is important here - // as we can try to render a screen before it was put - // to `loaded` screens - if (!loaded.includes(index) && !isFocused) { - // Don't render a screen if we've never navigated to it - return null; - } - - return ( - - - {descriptor.render()} - - - ); - })} - - - - - - - ); - } - const stackState: StackNavigationState = { - ...state, - type: 'stack', - }; + const onTabPress = React.useCallback( + (key: string) => { + if (state.routes[state.index].key === key) { + return; + } + navigation.navigate({ key }); + }, + [navigation, state.routes, state.index], + ); - // TODO: there could be issues on iOS with rendering - // need to check it and disable for iOS if it works badly - // if (Platform.OS === 'android' && screensEnabled()) { - if (doesSupportNative) { - return ( - - - - ); + const mainRoute = state.routes.find(({ name }: { name: string }) => name === MAIN_SCREEN_NAME); + if (mainRoute == null) { + throw new Error(`You should provide ${MAIN_SCREEN_NAME} screen!`); } - return ( - - {/* @ts-ignore */} - - + ); + } + + return ( + ); -}; +} -export const createSplitNavigator = createNavigatorFactory(SplitNavigator); +export const createSplitNavigator = createNavigatorFactory< + NavigationState, + SplitScreenOptions, + EventMapBase, + React.ComponentType +>(SplitNavigator); const styles = StyleSheet.create({ body: { @@ -226,3 +555,8 @@ const styles = StyleSheet.create({ flex: 1, }, }); +const defaultSplitStyles = { + body: styles.body, + main: styles.main, + detail: styles.detail, +}; diff --git a/casts/splitNavigator/types/index.d.ts b/casts/splitNavigator/types/index.d.ts new file mode 100644 index 000000000..c59c6fa8a --- /dev/null +++ b/casts/splitNavigator/types/index.d.ts @@ -0,0 +1 @@ +declare module 'react-native-simple-shadow-view'; diff --git a/casts/stackNavigator/src/StackNavigator/useStackTopInsetStyle.tsx b/casts/stackNavigator/src/StackNavigator/useStackTopInsetStyle.tsx index 1c8f7b468..0ce73ebf3 100644 --- a/casts/stackNavigator/src/StackNavigator/useStackTopInsetStyle.tsx +++ b/casts/stackNavigator/src/StackNavigator/useStackTopInsetStyle.tsx @@ -6,7 +6,7 @@ import { NestedInSplitContext } from '@tonlabs/uicast.split-navigator'; export function useStackTopInsetStyle() { const { top } = useSafeAreaInsets(); - const { isSplitted } = React.useContext(NestedInSplitContext); + const isSplitted = React.useContext(NestedInSplitContext); const closeModal = React.useContext(NestedInModalContext); const topInsetStyle = React.useMemo(() => { diff --git a/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx b/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx index 07c1abfe4..4c090ca48 100644 --- a/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx +++ b/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import type { ScrollViewProps } from 'react-native'; import Animated from 'react-native-reanimated'; -import { NativeViewGestureHandler, PanGestureHandler } from 'react-native-gesture-handler'; import { ScrollableContext } from '../Context'; import { useHasScroll } from './useHasScroll'; @@ -16,18 +15,10 @@ export function wrapScrollableComponent( props: Props & { children?: React.ReactNode }, forwardRef: React.RefObject, ) { - const nativeGestureRef = React.useRef(null); - const { onLayout, onContentSizeChange } = useHasScroll(); - const { - ref, - panGestureHandlerRef, - scrollHandler, - gestureHandler, - registerScrollable, - unregisterScrollable, - } = React.useContext(ScrollableContext); + const { ref, scrollHandler, registerScrollable, unregisterScrollable } = + React.useContext(ScrollableContext); React.useEffect(() => { if (registerScrollable) { @@ -47,31 +38,15 @@ export function wrapScrollableComponent( }); return ( - - - - {/* @ts-ignore */} - - - - + ); } diff --git a/yarn.lock b/yarn.lock index 6833152e8..1e4e6fc88 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11869,6 +11869,11 @@ react-dom@17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-freeze@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/react-freeze/-/react-freeze-1.0.0.tgz#b21c65fe1783743007c8c9a2952b1c8879a77354" + integrity sha512-yQaiOqDmoKqks56LN9MTgY06O0qQHgV4FUrikH357DydArSZHQhl0BJFqGKIZoTqi8JizF9Dxhuk1FIZD6qCaw== + "react-is@^16.12.0 || ^17.0.0", react-is@^17.0.1, react-is@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" @@ -12072,6 +12077,11 @@ react-native-share@^3.8.3: prop-types "*" uuid "^3.1.0" +react-native-simple-shadow-view@1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/react-native-simple-shadow-view/-/react-native-simple-shadow-view-1.6.3.tgz#bde06d9a35d9e03b57cf3772dd52409daa36661e" + integrity sha512-HulUAFWu4QCO/D2E4MU29bMRqevvFgcoN/cyzPRk4NRAdSJ7gnkG2MHhUQar3U4AwYBz+9aBb2qFy4LnM9KMNQ== + react-native-status-bar-height@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/react-native-status-bar-height/-/react-native-status-bar-height-1.0.1.tgz#5179e05646f99313439185dedd90d25e74b218ed"