From 6bd8f5cf8d76540a6481789ccd981bf9cc41f99a Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 9 Nov 2021 17:38:20 +0300 Subject: [PATCH 01/20] Drop stack from split navigator --- .../SplitNavigator/createSplitNavigator.tsx | 90 ++++++++++++------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 9ff09e77f..b441b873e 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -13,10 +13,8 @@ import { 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 { NativeStackView } from 'react-native-screens/native-stack'; import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; @@ -43,6 +41,11 @@ function SceneContent({ isFocused, children }: { isFocused: boolean; children: R ); } +const SplitTabBarHeightCallbackContext = React.createContext<((height: number) => void) | null>( + null, +); +const SplitTabBarHeightContext = React.createContext(0); + type SurfSplitNavigatorProps = { children?: React.ReactNode; initialRouteName: string; @@ -104,6 +107,8 @@ export const SplitNavigator = ({ } }, [state, loaded]); + const [tabBarHeight, setTabBarHeight] = React.useState(0); + // Access it from the state to re-render a container // only when router has processed SET_SPLITTED action if (state.isSplitted) { @@ -119,7 +124,13 @@ export const SplitNavigator = ({ - {descriptors[mainRoute.key].render()} + + {descriptors[mainRoute.key].render()} + + + {/* TODO */} + + = { - ...state, - type: 'stack', - }; - - // 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 ( + return ( + - + + + {state.routes.map((route, index) => { + const descriptor = descriptors[route.key]; + const isFocused = state.index === 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()} + + + + ); + })} + + + {/* TODO */} + + + - ); - } - return ( - - {/* @ts-ignore */} - - + ); }; From 4dc0e92ca159237f203353ff9ac44fee42b1f297 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Wed, 10 Nov 2021 18:50:28 +0300 Subject: [PATCH 02/20] Re-working router for new navigation --- .../src/SplitNavigator/SplitRouter.ts | 697 +++++++++++++----- .../SplitNavigator/createSplitNavigator.tsx | 279 ++++++- 2 files changed, 783 insertions(+), 193 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 652fa62ba..4f7f23041 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -11,6 +11,7 @@ import type { Router, StackNavigationState, TabNavigationState, + RouterConfigOptions, } from '@react-navigation/core'; type SplitActionType = @@ -49,17 +50,11 @@ export type SplitActionHelpers = { export const MAIN_SCREEN_NAME = 'main'; -type NavigationRoute = Route< - Extract, - ParamList[RouteName] -> & { - state?: NavigationState | PartialState; - order: number; -}; - export type SplitRouterOptions = DefaultRouterOptions> & { isSplitted: boolean; + tabRouteNames: string[]; + stackRouteNames: string[]; }; const stackStateToTab = ( @@ -139,134 +134,392 @@ const tabStateToStack = ( }; }; -type StackLikeSplitNavigationState = Omit< - StackNavigationState, - 'type' | 'history' +// export type SplitNavigationState = ( +// | StackLikeSplitNavigationState +// | TabLikeSplitNavigationState +// ) & { isSplitted?: boolean }; + +type MainRoute = Omit< + Route, ParamList[RouteName]>, + 'name' > & { - type: 'split'; - history?: ( - | NavigationRoute - | { - type: string; - key: string; - } - )[]; + name: typeof MAIN_SCREEN_NAME; + state: StackNavigationState; }; - -type TabLikeSplitNavigationState = Omit< - TabNavigationState, - 'type' | 'history' +type NavigationRoute = Route< + Extract, + ParamList[RouteName] > & { + state?: NavigationState | PartialState; +}; + +type SplitNavigationState = { + /** + * Unique key for the navigation state. + */ + key: string; + /** + * Index of the currently focused route. + */ + index: number; + /** + * List of valid route names as defined in the screen components. + */ + routeNames: string[]; + /** + * List of route names from `routeNames` that have to act as tabs. + */ + tabRouteNames: string[]; + /** + * List of route names from `routeNames` + * that have to be places in a nested stack for the main screen. + */ + stackRouteNames: string[]; + // TODO + // stackRouteNames: Extract[]; + /** + * 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'; - history?: ( - | NavigationRoute - | { - type: string; - key: string; - } - )[]; + /** + * List of previously visited route keys. + */ + history: string[]; + /** + * List of rendered routes. + */ + routes: (MainRoute | NavigationRoute)[]; + /** + * Whether it's splitted now or not + */ + isSplitted: boolean; }; -export type SplitNavigationState = ( - | StackLikeSplitNavigationState - | TabLikeSplitNavigationState -) & { isSplitted?: boolean }; +function splittedToShrinked( + state: SplitNavigationState, +): SplitNavigationState { + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + const mainRoute = state.routes[mainRouteIndex]; + const currentRoute = state.routes[state.index]; + const currentRouteName = currentRoute.name; + const currentRouteIsForStack = state.stackRouteNames.includes(currentRouteName); + + return { + ...state, + key: `split-${nanoid()}`, + index: currentRouteIsForStack + ? mainRouteIndex + : state.tabRouteNames.indexOf(currentRouteName), + // TODO + history: [], + routes: state.tabRouteNames.map(name => { + if (name === MAIN_SCREEN_NAME) { + const mainStackRouteNames = [MAIN_SCREEN_NAME, ...state.stackRouteNames]; + const mainStackRoutes: { + name: string; + key: string; + params: ParamList; + state?: StackNavigationState; + }[] = [ + { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: mainRoute.params, + }, + ]; + const hasNestedActiveRoute = + state.index !== mainRouteIndex && currentRouteIsForStack; + if (hasNestedActiveRoute) { + mainStackRoutes.push({ + name: currentRouteName, + key: `${currentRouteName}-${nanoid()}`, + params: currentRoute.params, + state: currentRoute.state, + }); + } + return { + name: MAIN_SCREEN_NAME, + key: `${name}-${nanoid()}`, + params: state.routes[mainRouteIndex].params, + state: { + stale: false, + type: 'stack', + key: `stack-${nanoid()}`, + index: hasNestedActiveRoute ? 1 : 0, + routeNames: mainStackRouteNames, + routes: mainStackRoutes, + }, + }; + } + const routeForName = state.routes[state.routeNames.indexOf(name)]; + return { + name, + key: `${name}-${nanoid()}`, + params: routeForName == null ? undefined : routeForName.params, + state: routeForName == null ? undefined : routeForName.state, + }; + }), + }; +} + +function shrinkedToSplitted( + state: SplitNavigationState, +): SplitNavigationState { + const mainRouteIndex = state.tabRouteNames.indexOf(MAIN_SCREEN_NAME); + const mainRoute = state.routes[mainRouteIndex] as MainRoute; + const currentRoute = state.routes[state.index]; + + let index = 0; + if (state.index === mainRouteIndex) { + if (mainRoute.state.index > 0) { + const currentNestedRoute = mainRoute.state.routes[mainRoute.state.index]; + index = state.routeNames.indexOf(currentNestedRoute.name); + } else { + index = state.routeNames.indexOf(MAIN_SCREEN_NAME); + } + } else { + index = state.routeNames.indexOf(currentRoute.name); + } + + return { + ...state, + key: `split-${nanoid()}`, + index, + // TODO + history: [], + routes: state.routeNames.map(name => { + if (name === MAIN_SCREEN_NAME) { + return { + name: MAIN_SCREEN_NAME, + key: `${name}-${nanoid()}`, + params: mainRoute.params, + state: { + stale: false, + type: 'stack', + key: `stack-${nanoid()}`, + index: 0, + routeNames: [MAIN_SCREEN_NAME], + routes: mainRoute.state.routes.slice(0, 1), + }, + }; + } + let routeForNameIndex = state.tabRouteNames.indexOf(name); + let routeForName: typeof state.routes[0] | null = null; + if (routeForNameIndex > -1) { + routeForName = state.routes[routeForNameIndex]; + } else { + routeForNameIndex = mainRoute.state.routeNames.indexOf(name); + if (routeForNameIndex > -1) { + routeForName = mainRoute.state.routes[routeForNameIndex]; + } + } + return { + name, + key: `${name}-${nanoid()}`, + params: routeForName == null ? undefined : routeForName.params, + state: routeForName == null ? undefined : routeForName.state, + }; + }), + }; +} export function SplitRouter(routerOptions: SplitRouterOptions) { // eslint-disable-next-line prefer-const - let { isSplitted, initialRouteName, ...tabOptions } = routerOptions; + let { isSplitted, initialRouteName, tabRouteNames, stackRouteNames, ...tabOptions } = + routerOptions; const { ...stackOptions } = tabOptions; const tabRouter = TabRouter({ ...tabOptions, initialRouteName, }); const stackRouter = StackRouter({ + // TODO: what options? ...stackOptions, + // TODO: I'm not sure that it should be main, better to check if initialRouteName + // is in stack related routes and get it initialRouteName: MAIN_SCREEN_NAME, }); let isInitialized = false; - const router: Router & { - ensureTabState(state: SplitNavigationState): SplitNavigationState; - ensureStackState(state: SplitNavigationState): SplitNavigationState; - } = { + 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 - // with every re-render + // every time type: 'split', - 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); - } else { - // @ts-ignore index is read-only in type declaration - newState.index += 1; - } - } - - return newState; - }, - - ensureStackState(newState: SplitNavigationState) { - const mainRoute = newState.routes.find(({ name }) => name === MAIN_SCREEN_NAME) || { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - }; - 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]; + getInitialState({ + routeNames, + routeParamList, + routeGetIdList, + }: RouterConfigOptions): SplitNavigationState { + /** + * 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. + */ + if (isSplitted) { + const index = + // TODO: do we really need to check for includes, isn't it get -1 another way? + initialRouteName != null && routeNames.includes(initialRouteName) + ? routeNames.indexOf(initialRouteName) + : 0; + return { + key: `split-${nanoid()}`, + index, + routeNames, + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], + routes: routeNames.map(name => { + if (name === MAIN_SCREEN_NAME) { + return { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: routeParamList[MAIN_SCREEN_NAME], + /** + * We have to create a stack navigation for main screen + * to be able to use large header there with regular `navigationOptions` + */ + state: stackRouter.getInitialState({ + routeNames: [MAIN_SCREEN_NAME], + routeParamList, + routeGetIdList, + }), + }; + } + return { + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + }; + }), + isSplitted, + }; } - return newState; - }, - - getInitialState(params) { - let newState: SplitNavigationState; - - if (isSplitted) { - newState = tabRouter.getInitialState(params) as any; - newState = this.ensureTabState(newState); + /** + * Things got more interesting here, because we have two types of routes: + * - 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. + */ + + /** + * First challenge 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 + */ + + let index = 0; + let stackIndex = 0; + let history = [MAIN_SCREEN_NAME]; + if (initialRouteName == null) { + // TODO: no-op? + } else if (tabRouteNames.includes(initialRouteName)) { + index = tabRouteNames.indexOf(initialRouteName); + history = [initialRouteName]; } else { - newState = stackRouter.getInitialState(params) as any; - newState = this.ensureStackState(newState); + // it's a route for stack + index = tabRouteNames.indexOf(MAIN_SCREEN_NAME); + // It's 1 as nested state will consist of two items: + // 0. main screen + // 1. initial screen, that is going to be active + stackIndex = 1; } - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + return { + key: `split-${nanoid()}`, + index, + routeNames, + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + history, + routes: tabRouteNames + // TODO: it's seems to be a more stable solution + // But on the other hand for performance reason better to avoid it. + // For now I can't imagine a case when it might break sth. + // routeNames.filter(route => tabRouteNames.includes(route)) + .map(name => { + if (name === MAIN_SCREEN_NAME) { + /** + * Second challenge is to create a correct state + * for nested stack navigation here + */ + // TODO: it's seems to be a more stable solution + // But on the other hand for performance reason better to avoid it. + // For now I can't imagine a case when it might break sth. + // routeNames.filter(route => stackRouteNames.includes(route)) + const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; + const mainStackRoutes = [ + { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: routeParamList[MAIN_SCREEN_NAME], + }, + ]; + // It means that initial route have to be in nested stack + if (initialRouteName != null && stackIndex > 0) { + mainStackRoutes.push({ + name: initialRouteName, + key: `${initialRouteName}-${nanoid()}`, + params: routeParamList[initialRouteName], + }); + } + return { + name: MAIN_SCREEN_NAME, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + state: { + stale: false, + type: 'stack', + key: `stack-${nanoid()}`, + index: stackIndex, + routeNames: mainStackRouteNames, + routes: mainStackRoutes, + }, + }; + } + return { + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + }; + }), + isSplitted, + }; }, - getRehydratedState(state, params): SplitNavigationState { + // TODO: I forgot what it's for :thinking: + // probably to handle initial state from linking + getRehydratedState(state): 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 state as any; }, getStateForRouteNamesChange(state, options) { @@ -288,7 +541,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { }, getStateForAction(state: SplitNavigationState, action, options) { - let newState: SplitNavigationState = state; if (action.type === 'SET_SPLITTED') { ({ isSplitted } = action.payload); @@ -302,93 +554,59 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { } 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, - ); - - if (tempState != null) { - newState = tempState as any; - } + return shrinkedToSplitted(state); } - } else { - newState = isSplitted - ? (tabRouter.getStateForAction(state as any, action, options) as any) - : stackRouter.getStateForAction(state as any, action, options); - - if (newState == null) { + return splittedToShrinked(state); + } + if (action.type === 'RESET_TO_INITIAL') { + // TODO + return state; + } + if (action.type === 'GO_BACK') { + // TODO + return state; + } + if (action.type === 'NAVIGATE') { + const { name, params } = action.payload; + // TODO: should we handle a key? + if (name == 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]; - } + if (state.isSplitted) { + // In splitted mode we always should apply tab navigation + const index = state.routeNames.indexOf(name); + return applyNavigateActionToState(state, action, options, index); + } + if (state.tabRouteNames.includes(name)) { + // In shrinked mode need to apply simple tab navigation + // only for routes that are in the bar + const index = state.tabRouteNames.indexOf(name); + return applyNavigateActionToState(state, action, options, index); } + // For stack routes in shrinked mode we have to call + // stack router method on nested navigation in the main route + + // TODO: check that it should applied first! + return { + ...state, + routes: state.routes.map(route => { + if (route.name !== MAIN_SCREEN_NAME) { + return route; + } + if (route.state == null) { + return route; + } + return { + ...route, + state: stackRouter.getStateForAction(route.state, action, options), + }; + }), + }; } + // TODO: left here!!!!!!!! - Object.assign(newState, { type: router.type, isSplitted }); - return newState; + // TODO: there was sth about history, need to check it + return null; }, shouldActionChangeFocus(action) { @@ -398,3 +616,124 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return router; } + +function applyNavigateActionToState( + state: SplitNavigationState, + action: CommonNavigationAction, + options: RouterConfigOptions, + index: number, +) { + if (action.type !== 'NAVIGATE') { + return null; + } + return { + ...state, + index, + routes: + 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; + }), + }; +} + +// splitted=true +// { +// stale: false, +// type: 'split', +// key: `split-${nanoid()}`, +// index: 0, // initial +// routeNames: ['main', 'buttons', 'carousel'], +// // history, +// routes: [ +// { +// key: `main-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// // for stack +// { +// key: `buttons-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// // for tab +// { +// key: `carousel-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// ], +// } + +// splitted=false +// { +// stale: false, +// type: 'split', +// key: `split-${nanoid()}`, +// index: 0, // initial +// routeNames: ['main', 'carousel'], +// // history, +// routes: [ +// // main +// { +// key: `main-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// state: { +// stale: false, +// type: 'stack', +// key: `stack-${nanoid()}`, +// index: 0, +// routeNames: ['main', 'buttons'], +// routes: [ +// { +// key: `main-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// { +// key: `buttons-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// ], +// } +// }, +// // for tab +// { +// key: `carousel-${nanoid()}`, +// // name, +// // params: routeParamList[name], +// }, +// ], +// } diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index b441b873e..e319f3237 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -6,7 +6,11 @@ import { ViewStyle, Platform, StyleProp, + Animated, + ImageSourcePropType, + ImageRequireSource, } from 'react-native'; +import type { EventMapBase, NavigationState } from '@react-navigation/core'; import { NavigationHelpersContext, useNavigationBuilder, @@ -16,6 +20,8 @@ import { import type { NavigationProp, ParamListBase } from '@react-navigation/native'; import { screensEnabled, ScreenContainer } from 'react-native-screens'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { UIImage } from '@tonlabs/uikit.media'; import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; import { SplitRouter, SplitActions, MAIN_SCREEN_NAME, SplitActionHelpers } from './SplitRouter'; @@ -46,7 +52,7 @@ const SplitTabBarHeightCallbackContext = React.createContext<((height: number) = ); const SplitTabBarHeightContext = React.createContext(0); -type SurfSplitNavigatorProps = { +type SplitNavigatorProps = { children?: React.ReactNode; initialRouteName: string; mainWidth: number; @@ -59,12 +65,212 @@ type SurfSplitNavigatorProps = { } & SplitRouterOptions; }; -export const SplitNavigator = ({ +type SplitScreenTabBarIconOptions = + | { + tabBarActiveIcon: ImageSourcePropType; + tabBarDisabledIcon: ImageSourcePropType; + } + | { + tabBarIconLottieSource: ImageRequireSource; + }; +type SplitScreenOptions = SplitScreenTabBarIconOptions | {}; + +/** + * TODO + */ +function LottieView(_props: { + source: ImageSourcePropType; + progress: Animated.Value; + style: StyleProp; +}) { + return null; +} + +type SplitTabBarIconRef = { + activate(): void; + disable(): void; +}; + +type LottieIconViewProps = { + defaultActiveState: boolean; + source: ImageRequireSource; +}; +const LottieIconView = React.forwardRef( + function LottieIconWrapper({ defaultActiveState, source }: LottieIconViewProps, ref) { + const progress = React.useRef(new Animated.Value(defaultActiveState ? 1 : 0)).current; + React.useImperativeHandle(ref, () => ({ + activate() { + /** + * TODO: maybe linear is better to keep it in sync with the dot? + */ + Animated.spring(progress, { + toValue: 1, + useNativeDriver: true, + }); + }, + disable() { + Animated.spring(progress, { + toValue: 0, + useNativeDriver: true, + }); + }, + })); + + return ( + + ); + }, +); + +type ImageIconViewProps = { + defaultActiveState: boolean; + activeSource: ImageSourcePropType; + disabledSource: ImageSourcePropType; +}; +const ImageIconView = React.forwardRef( + function ImageIconView( + { defaultActiveState, activeSource, disabledSource }: ImageIconViewProps, + ref, + ) { + const [active, setActive] = React.useState(defaultActiveState); + React.useImperativeHandle(ref, () => ({ + activate() { + setActive(true); + }, + disable() { + setActive(false); + }, + })); + + return ( + + ); + }, +); + +function SplitBottomTabBar({ + icons, + setTabBarHeight, + activeKey, +}: { + icons: Record; + setTabBarHeight: (height: number) => void; + activeKey: string; +}) { + const insets = useSafeAreaInsets(); + + React.useEffect(() => { + setTabBarHeight(Math.max(insets?.bottom, 32 /* TODO */) + 64 /* TODO */); + }, [insets?.bottom, setTabBarHeight]); + + const iconsRefs = React.useRef>>({}); + + const prevActiveKey = React.useRef(activeKey); + React.useEffect(() => { + if (activeKey === prevActiveKey.current) { + return; + } + + prevActiveKey.current = activeKey; + iconsRefs.current[prevActiveKey.current]?.current?.disable(); + iconsRefs.current[activeKey]?.current?.activate(); + // TODO: move the dot + }, [activeKey]); + + /** + * 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 ( + { + setTabBarHeight(height); + }} + > + + {Object.keys(icons).map(key => { + const icon = icons[key]; + if (iconsRefs.current[key] == null) { + iconsRefs.current[key] = React.createRef(); + } + if ('tabBarIconLottieSource' in icon) { + return ( + + + + ); + } + return ( + + + + ); + })} + + + ); +} + +export function SplitNavigator({ children, initialRouteName, mainWidth, screenOptions, -}: SurfSplitNavigatorProps) => { +}: SplitNavigatorProps) { const dimensions = useWindowDimensions(); const isSplitted = getIsSplitted(dimensions, mainWidth); const doesSupportNative = Platform.OS !== 'web' && screensEnabled?.(); @@ -79,7 +285,7 @@ export const SplitNavigator = ({ SplitNavigationState, SplitRouterOptions, SplitActionHelpers, - SplitRouterOptions, + SplitScreenOptions, NavigationProp >(SplitRouter, { children, @@ -118,6 +324,28 @@ export const SplitNavigator = ({ if (mainRoute == null) { throw new Error(`You should provide ${MAIN_SCREEN_NAME} screen!`); } + + const tabBarIcons = Object.keys(descriptors).reduce< + Record + >((acc, key) => { + if (key === mainRoute.key) { + return acc; + } + + const descriptor = descriptors[key]; + if (descriptor.options == null) { + return acc; + } + if ('tabBarActiveIcon' in descriptor.options) { + acc[key] = descriptor.options; + } + if ('tabBarIconLottieSource' in descriptor.options) { + acc[key] = descriptor.options; + } + + return acc; + }, {}); + return ( @@ -127,10 +355,11 @@ export const SplitNavigator = ({ {descriptors[mainRoute.key].render()} - - {/* TODO */} - - + + >((acc, key) => { + const descriptor = descriptors[key]; + if (descriptor.options == null) { + return acc; + } + if ('tabBarActiveIcon' in descriptor.options) { + acc[key] = descriptor.options; + } + if ('tabBarIconLottieSource' in descriptor.options) { + acc[key] = descriptor.options; + } + + return acc; + }, {}); return ( @@ -216,17 +461,23 @@ export const SplitNavigator = ({ ); })} - - {/* TODO */} - - + ); -}; +} -export const createSplitNavigator = createNavigatorFactory(SplitNavigator); +export const createSplitNavigator = createNavigatorFactory< + NavigationState, + SplitScreenOptions, + EventMapBase, + React.ComponentType +>(SplitNavigator); const styles = StyleSheet.create({ body: { From 36e80a0137f523d26f797b6cb90ebe35132dc112 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Thu, 11 Nov 2021 14:38:15 +0300 Subject: [PATCH 03/20] Basic behaviour --- .../src/SplitNavigator/SplitRouter.ts | 914 ++++++++++-------- .../SplitNavigator/createSplitNavigator.tsx | 44 +- 2 files changed, 546 insertions(+), 412 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 4f7f23041..d66a502cc 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, @@ -10,7 +12,6 @@ import type { Route, Router, StackNavigationState, - TabNavigationState, RouterConfigOptions, } from '@react-navigation/core'; @@ -50,100 +51,18 @@ export type SplitActionHelpers = { export const MAIN_SCREEN_NAME = 'main'; -export type SplitRouterOptions = - DefaultRouterOptions> & { - isSplitted: boolean; - tabRouteNames: string[]; - stackRouteNames: string[]; - }; - -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); - } - - const routes = state.routeNames.map(name => { - const route = state.routes.find(({ name: routeName }) => routeName === name); - - if (route) { - return { - ...route, - // change a route key, to force re-render of the screen - // to avoid race-conditions in nested navigation - key: `${route.name}-${nanoid()}`, - }; - } - return { - name, - key: `${name}-${nanoid()}`, - }; - }) as TabLikeSplitNavigationState['routes']; - - return { - ...state, - index, - routes, - history: [currentRoute], - }; -}; - -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; - } - 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]; - } - return { - ...state, - index, - routes, - }; +type SplitRouterCustomOptions = { + isSplitted: boolean; + tabRouteNames: string[]; + stackRouteNames: string[]; }; +export type SplitRouterOptions = + DefaultRouterOptions> & SplitRouterCustomOptions; -// export type SplitNavigationState = ( -// | StackLikeSplitNavigationState -// | TabLikeSplitNavigationState -// ) & { isSplitted?: boolean }; - -type MainRoute = Omit< - Route, ParamList[RouteName]>, - 'name' +type MainRoute = Route< + Extract, + ParamList[RouteName] > & { - name: typeof MAIN_SCREEN_NAME; state: StackNavigationState; }; type NavigationRoute = Route< @@ -153,7 +72,7 @@ type NavigationRoute; }; -type SplitNavigationState = { +export type SplitNavigationState = { /** * Unique key for the navigation state. */ @@ -201,7 +120,7 @@ type SplitNavigationState = { isSplitted: boolean; }; -function splittedToShrinked( +function fold( state: SplitNavigationState, ): SplitNavigationState { const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); @@ -218,19 +137,20 @@ function splittedToShrinked( : state.tabRouteNames.indexOf(currentRouteName), // TODO history: [], + // @ts-ignore routes: state.tabRouteNames.map(name => { if (name === MAIN_SCREEN_NAME) { const mainStackRouteNames = [MAIN_SCREEN_NAME, ...state.stackRouteNames]; const mainStackRoutes: { - name: string; + name: Extract; key: string; params: ParamList; state?: StackNavigationState; }[] = [ { - name: MAIN_SCREEN_NAME, + name: MAIN_SCREEN_NAME as Extract, key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: mainRoute.params, + params: mainRoute.params as any, }, ]; const hasNestedActiveRoute = @@ -239,8 +159,8 @@ function splittedToShrinked( mainStackRoutes.push({ name: currentRouteName, key: `${currentRouteName}-${nanoid()}`, - params: currentRoute.params, - state: currentRoute.state, + params: currentRoute.params as any, + state: currentRoute.state as any, }); } return { @@ -268,7 +188,7 @@ function splittedToShrinked( }; } -function shrinkedToSplitted( +function unfold( state: SplitNavigationState, ): SplitNavigationState { const mainRouteIndex = state.tabRouteNames.indexOf(MAIN_SCREEN_NAME); @@ -294,27 +214,12 @@ function shrinkedToSplitted( // TODO history: [], routes: state.routeNames.map(name => { - if (name === MAIN_SCREEN_NAME) { - return { - name: MAIN_SCREEN_NAME, - key: `${name}-${nanoid()}`, - params: mainRoute.params, - state: { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: 0, - routeNames: [MAIN_SCREEN_NAME], - routes: mainRoute.state.routes.slice(0, 1), - }, - }; - } let routeForNameIndex = state.tabRouteNames.indexOf(name); let routeForName: typeof state.routes[0] | null = null; if (routeForNameIndex > -1) { routeForName = state.routes[routeForNameIndex]; } else { - routeForNameIndex = mainRoute.state.routeNames.indexOf(name); + routeForNameIndex = mainRoute.state.routeNames.indexOf(name as any); if (routeForNameIndex > -1) { routeForName = mainRoute.state.routes[routeForNameIndex]; } @@ -329,215 +234,506 @@ function shrinkedToSplitted( }; } -export function SplitRouter(routerOptions: SplitRouterOptions) { - // eslint-disable-next-line prefer-const - let { isSplitted, initialRouteName, tabRouteNames, stackRouteNames, ...tabOptions } = - routerOptions; - const { ...stackOptions } = tabOptions; - const tabRouter = TabRouter({ - ...tabOptions, - initialRouteName, - }); - const stackRouter = StackRouter({ - // TODO: what options? - ...stackOptions, - // TODO: I'm not sure that it should be main, better to check if initialRouteName - // is in stack related routes and get it - initialRouteName: MAIN_SCREEN_NAME, - }); - let isInitialized = false; - const router: Router = { - ...BaseRouter, +function applyTabNavigateActionToState( + state: SplitNavigationState, + action: CommonNavigationAction, + options: RouterConfigOptions, + index: number, +): SplitNavigationState | null { + if (action.type !== 'NAVIGATE') { + return null; + } + return { + ...state, + index, + routes: + action.payload.params == null + ? state.routes + : state.routes.map((route, i) => { + if (i === index) { + return route; + } - // 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', + /** + * 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; + }), + }; +} + +class SplitUnfoldedRouter { + options: SplitRouterOptions; - getInitialState({ + constructor(options: SplitRouterOptions) { + this.options = options; + } + + /* + * 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, tabRouteNames, stackRouteNames, isSplitted } = this.options; + const index = + // TODO: do we really need to check for includes, isn't it get -1 another way? + initialRouteName != null && routeNames.includes(initialRouteName) + ? routeNames.indexOf(initialRouteName) + : 0; + return { + key: `split-${nanoid()}`, + index, routeNames, - routeParamList, - routeGetIdList, - }: RouterConfigOptions): SplitNavigationState { - /** - * 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. - */ - if (isSplitted) { - const index = - // TODO: do we really need to check for includes, isn't it get -1 another way? - initialRouteName != null && routeNames.includes(initialRouteName) - ? routeNames.indexOf(initialRouteName) - : 0; + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + isSplitted, + history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], + 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 { - key: `split-${nanoid()}`, - index, - routeNames, - tabRouteNames, - stackRouteNames, - stale: false, - type: 'split', - history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], - routes: routeNames.map(name => { - if (name === MAIN_SCREEN_NAME) { - return { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: routeParamList[MAIN_SCREEN_NAME], - /** - * We have to create a stack navigation for main screen - * to be able to use large header there with regular `navigationOptions` - */ - state: stackRouter.getInitialState({ - routeNames: [MAIN_SCREEN_NAME], - routeParamList, - routeGetIdList, - }), - }; - } - return { - name, - key: `${name}-${nanoid()}`, - params: routeParamList[name], - }; - }), - isSplitted, + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], }; - } + }), + }; + } - /** - * Things got more interesting here, because we have two types of routes: - * - 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. - */ - - /** - * First challenge 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 - */ - - let index = 0; - let stackIndex = 0; - let history = [MAIN_SCREEN_NAME]; - if (initialRouteName == null) { - // TODO: no-op? - } else if (tabRouteNames.includes(initialRouteName)) { - index = tabRouteNames.indexOf(initialRouteName); - history = [initialRouteName]; + getRehydratedState( + partialState: PartialState>, + { routeNames, routeParamList }: RouterConfigOptions, + ): SplitNavigationState { + const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; + + const routes = routeNames.map(name => { + const route = partialState.routes?.find(r => r.name === name) ?? null; + return { + ...route, + name, + key: route && route.name === name && route.key ? route.key : `${name}-${nanoid()}`, + params: + routeParamList[name] != null + ? { ...routeParamList[name], ...route?.params } + : route?.params, + }; + }); + + let index = 0; + const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; + if (activeRouteName != null) { + if (routeNames.includes(activeRouteName)) { + // set provided route as initial + index = routeNames.indexOf(activeRouteName); } else { - // it's a route for stack - index = tabRouteNames.indexOf(MAIN_SCREEN_NAME); - // It's 1 as nested state will consist of two items: - // 0. main screen - // 1. initial screen, that is going to be active + /** + * Sth went wrong and provided route wasn't found + * TODO: Maybe it's a good place to redirect to 404 + * + * TODO: not sure about main though + */ + index = routeNames.indexOf(initialRouteName || MAIN_SCREEN_NAME); + } + } else { + // set initial otherwise + // TODO: not sure about main though + index = routeNames.indexOf(initialRouteName || MAIN_SCREEN_NAME); + } + + return { + key: `split-${nanoid()}`, + index, + routeNames, + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + isSplitted, + history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], + // @ts-ignore + routes, + }; + } + + getStateForAction( + state: SplitNavigationState, + action: CommonNavigationAction | SplitActionType, + options: RouterConfigOptions, + ): SplitNavigationState | null { + if (action.type === 'RESET_TO_INITIAL') { + // TODO + return state; + } + if (action.type === 'GO_BACK') { + // TODO + return null; + } + if (action.type === 'NAVIGATE') { + const { name } = action.payload; + // TODO: should we handle a key? + if (name == null) { + return null; + } + // In unfolded mode we always should apply tab navigation + const index = state.routeNames.indexOf(name); + return applyTabNavigateActionToState(state, action, options, index); + } + return null; + } +} + +class SplitFoldedRouter { + options: SplitRouterOptions; + + stackRouter: ReturnType; + + constructor(options: SplitRouterOptions) { + this.options = options; + this.stackRouter = StackRouter({}); + } + + /** + * 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, tabRouteNames, stackRouteNames, isSplitted } = this.options; + /** + * First challenge 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 + */ + let index = 0; + let stackIndex = 0; + let history = [MAIN_SCREEN_NAME]; + if (initialRouteName == null) { + // TODO: no-op? + } else if (tabRouteNames.includes(initialRouteName)) { + index = tabRouteNames.indexOf(initialRouteName); + history = [initialRouteName]; + } else { + // it's a route for stack + index = tabRouteNames.indexOf(MAIN_SCREEN_NAME); + // It's 1 as nested state will consist of two items: + // 0. main screen + // 1. initial screen, that is going to be active + stackIndex = 1; + history = [MAIN_SCREEN_NAME]; + } + + return { + key: `split-${nanoid()}`, + index, + routeNames, + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + history, + routes: tabRouteNames.map(name => { + if (name === MAIN_SCREEN_NAME) { + /** + * Second challenge is to create a correct state + * for nested stack navigation here + */ + const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; + const mainStackRoutes = [ + { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: routeParamList[MAIN_SCREEN_NAME], + }, + ]; + // It means that initial route have to be in nested stack + if (initialRouteName != null && stackIndex > 0) { + mainStackRoutes.push({ + name: initialRouteName, + key: `${initialRouteName}-${nanoid()}`, + params: routeParamList[initialRouteName], + }); + } + return { + name: MAIN_SCREEN_NAME, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + state: { + stale: false, + type: 'stack', + key: `stack-${nanoid()}`, + index: stackIndex, + routeNames: mainStackRouteNames, + routes: mainStackRoutes, + }, + }; + } + return { + name, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + }; + }), + isSplitted, + }; + } + + getRehydratedState( + partialState: PartialState>, + { routeNames, routeParamList }: RouterConfigOptions, + ): SplitNavigationState { + const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; + + let rootIndex = 0; + let stackIndex = 0; + const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; + let history = [MAIN_SCREEN_NAME]; + if (activeRouteName != null) { + if (tabRouteNames.includes(activeRouteName)) { + // set an index for a regular tab route + rootIndex = tabRouteNames.indexOf(activeRouteName); + history = [activeRouteName]; + } else if (stackRouteNames.includes(activeRouteName)) { + // set it as a main and then set index for nested stack + rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); stackIndex = 1; + // history is already set + } else if (initialRouteName) { + // nothing was found in known routes + // TODO: Maybe it's a good place to redirect to 404 + // tring to find initial route + if (tabRouteNames.includes(initialRouteName)) { + rootIndex = tabRouteNames.indexOf(initialRouteName); + history = [initialRouteName]; + } else { + // it's a route for stack + rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); + stackIndex = 1; + // history is already set + } + } else { + // at last if we found nothing at all, trying to set the main + rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); + // history is already set } + } + const routes = tabRouteNames.map(name => { + const route = partialState.routes?.find(r => r.name === name); + if (name === MAIN_SCREEN_NAME) { + const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; + const mainStackRoutes = [ + { + name: MAIN_SCREEN_NAME, + key: `${MAIN_SCREEN_NAME}-${nanoid()}`, + params: routeParamList[MAIN_SCREEN_NAME], + }, + ]; + if (activeRouteName != null && stackIndex > 0) { + const activeRoute = partialState.routes?.find(r => r.name === activeRouteName); + mainStackRoutes.push({ + ...activeRoute, + name: activeRouteName, + key: `${activeRouteName}-${nanoid()}`, + params: + routeParamList[activeRouteName] != null + ? { ...routeParamList[activeRouteName], ...route?.params } + : route?.params, + }); + } + return { + name: MAIN_SCREEN_NAME, + key: `${name}-${nanoid()}`, + params: routeParamList[name], + state: { + stale: false, + type: 'stack', + key: `stack-${nanoid()}`, + index: stackIndex, + routeNames: mainStackRouteNames, + routes: mainStackRoutes, + }, + }; + } return { - key: `split-${nanoid()}`, - index, - routeNames, - tabRouteNames, - stackRouteNames, - stale: false, - type: 'split', - history, - routes: tabRouteNames - // TODO: it's seems to be a more stable solution - // But on the other hand for performance reason better to avoid it. - // For now I can't imagine a case when it might break sth. - // routeNames.filter(route => tabRouteNames.includes(route)) - .map(name => { - if (name === MAIN_SCREEN_NAME) { - /** - * Second challenge is to create a correct state - * for nested stack navigation here - */ - // TODO: it's seems to be a more stable solution - // But on the other hand for performance reason better to avoid it. - // For now I can't imagine a case when it might break sth. - // routeNames.filter(route => stackRouteNames.includes(route)) - const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; - const mainStackRoutes = [ - { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: routeParamList[MAIN_SCREEN_NAME], - }, - ]; - // It means that initial route have to be in nested stack - if (initialRouteName != null && stackIndex > 0) { - mainStackRoutes.push({ - name: initialRouteName, - key: `${initialRouteName}-${nanoid()}`, - params: routeParamList[initialRouteName], - }); - } - return { - name: MAIN_SCREEN_NAME, - key: `${name}-${nanoid()}`, - params: routeParamList[name], - state: { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: stackIndex, - routeNames: mainStackRouteNames, - routes: mainStackRoutes, - }, - }; - } - return { - name, - key: `${name}-${nanoid()}`, - params: routeParamList[name], - }; - }), - isSplitted, + ...route, + name, + key: route && route.name === name && route.key ? route.key : `${name}-${nanoid()}`, + params: + routeParamList[name] != null + ? { ...routeParamList[name], ...route?.params } + : route?.params, + }; + }); + + return { + key: `split-${nanoid()}`, + index: rootIndex, + routeNames, + tabRouteNames, + stackRouteNames, + stale: false, + type: 'split', + isSplitted, + history, + // @ts-ignore + routes, + }; + } + + getStateForAction( + state: SplitNavigationState, + action: CommonNavigationAction | SplitActionType, + options: RouterConfigOptions, + ): SplitNavigationState | null { + if (action.type === 'RESET_TO_INITIAL') { + // TODO + return state; + } + if (action.type === 'GO_BACK') { + // TODO + return state; + } + if (action.type === 'NAVIGATE') { + const { name } = action.payload; + // TODO: should we handle a key? + if (name == null) { + return null; + } + if (state.tabRouteNames.includes(name)) { + // In shrinked mode need to apply simple tab navigation + // only for routes that are in the bar + const index = state.tabRouteNames.indexOf(name); + return applyTabNavigateActionToState(state, action, options, index); + } + // For stack routes in shrinked mode we have to call + // stack router method on nested navigation in the main route + return { + ...state, + // @ts-ignore + routes: state.routes.map(route => { + if (route.name !== MAIN_SCREEN_NAME) { + return route; + } + if (route.state == null) { + return route; + } + return { + ...route, + state: this.stackRouter.getStateForAction( + route.state as any, + action, + options, + ), + }; + }), }; + } + 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); }, - // TODO: I forgot what it's for :thinking: - // probably to handle initial state from linking - getRehydratedState(state): 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, options): SplitNavigationState { const isStale = state.stale; if (isStale === false) { - return state as SplitNavigationState; + return state; } - return state as any; + 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); + // TODO + 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; + // Object.assign(newState, { type: router.type, isSplitted }); + return state; }, - getStateForRouteFocus(state, key) { - const newState: SplitNavigationState = isSplitted - ? (tabRouter.getStateForRouteFocus(state as any, key) as any) - : stackRouter.getStateForRouteFocus(state as any, key); + // TODO + 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; + // Object.assign(newState, { type: router.type, isSplitted }); + return state; }, getStateForAction(state: SplitNavigationState, action, options) { @@ -550,122 +746,32 @@ 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) { - return shrinkedToSplitted(state); + return fold(state); } - return splittedToShrinked(state); + return unfold(state); } - if (action.type === 'RESET_TO_INITIAL') { - // TODO - return state; - } - if (action.type === 'GO_BACK') { - // TODO - return state; - } - if (action.type === 'NAVIGATE') { - const { name, params } = action.payload; - // TODO: should we handle a key? - if (name == null) { - return null; - } - if (state.isSplitted) { - // In splitted mode we always should apply tab navigation - const index = state.routeNames.indexOf(name); - return applyNavigateActionToState(state, action, options, index); - } - if (state.tabRouteNames.includes(name)) { - // In shrinked mode need to apply simple tab navigation - // only for routes that are in the bar - const index = state.tabRouteNames.indexOf(name); - return applyNavigateActionToState(state, action, options, index); - } - // For stack routes in shrinked mode we have to call - // stack router method on nested navigation in the main route - - // TODO: check that it should applied first! - return { - ...state, - routes: state.routes.map(route => { - if (route.name !== MAIN_SCREEN_NAME) { - return route; - } - if (route.state == null) { - return route; - } - return { - ...route, - state: stackRouter.getStateForAction(route.state, action, options), - }; - }), - }; - } - // TODO: left here!!!!!!!! - - // TODO: there was sth about history, need to check it - return null; + return (state.isSplitted ? unfoldedRouter : foldedRouter).getStateForAction( + state, + action, + options, + ); }, - shouldActionChangeFocus(action) { - return tabRouter.shouldActionChangeFocus(action); + shouldActionChangeFocus(/* action */) { + // TODO + // return tabRouter.shouldActionChangeFocus(action); + return false; }, }; return router; } -function applyNavigateActionToState( - state: SplitNavigationState, - action: CommonNavigationAction, - options: RouterConfigOptions, - index: number, -) { - if (action.type !== 'NAVIGATE') { - return null; - } - return { - ...state, - index, - routes: - 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; - }), - }; -} - // splitted=true // { // stale: false, diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index e319f3237..f533a599c 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -47,9 +47,6 @@ function SceneContent({ isFocused, children }: { isFocused: boolean; children: R ); } -const SplitTabBarHeightCallbackContext = React.createContext<((height: number) => void) | null>( - null, -); const SplitTabBarHeightContext = React.createContext(0); type SplitNavigatorProps = { @@ -73,7 +70,7 @@ type SplitScreenTabBarIconOptions = | { tabBarIconLottieSource: ImageRequireSource; }; -type SplitScreenOptions = SplitScreenTabBarIconOptions | {}; +type SplitScreenOptions = SplitScreenTabBarIconOptions | Record; /** * TODO @@ -281,6 +278,25 @@ export function SplitNavigator({ main: styles.main, detail: styles.detail, }; + const { tabRouteNames, stackRouteNames } = React.Children.toArray(children).reduce<{ + tabRouteNames: string[]; + stackRouteNames: string[]; + }>( + (acc, child) => { + if (React.isValidElement(child)) { + if ('tabBarActiveIcon' in child.props || 'tabBarIconLottieSource' in child.props) { + acc.tabRouteNames.push(child.props.name); + } else { + acc.tabRouteNames.push(child.props.name); + } + } + return acc; + }, + { + tabRouteNames: [], + stackRouteNames: [], + }, + ); const { state, navigation, descriptors } = useNavigationBuilder< SplitNavigationState, SplitRouterOptions, @@ -296,6 +312,8 @@ export function SplitNavigator({ // but it's needed to turn of header in react-native-screens headerShown: false, }, + tabRouteNames, + stackRouteNames, isSplitted, }); @@ -337,10 +355,15 @@ export function SplitNavigator({ return acc; } if ('tabBarActiveIcon' in descriptor.options) { - acc[key] = descriptor.options; + acc[key] = { + tabBarActiveIcon: descriptor.options.tabBarActiveIcon, + tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, + }; } if ('tabBarIconLottieSource' in descriptor.options) { - acc[key] = descriptor.options; + acc[key] = { + tabBarIconLottieSource: descriptor.options.tabBarIconLottieSource, + }; } return acc; @@ -415,10 +438,15 @@ export function SplitNavigator({ return acc; } if ('tabBarActiveIcon' in descriptor.options) { - acc[key] = descriptor.options; + acc[key] = { + tabBarActiveIcon: descriptor.options.tabBarActiveIcon, + tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, + }; } if ('tabBarIconLottieSource' in descriptor.options) { - acc[key] = descriptor.options; + acc[key] = { + tabBarIconLottieSource: descriptor.options.tabBarIconLottieSource, + }; } return acc; From 59efe71b9a87d134a8076477f404c002c7981bae Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Thu, 11 Nov 2021 18:59:38 +0300 Subject: [PATCH 04/20] Rework to keep it flat --- .../src/SplitNavigator/SplitRouter.ts | 356 ++++++++---------- .../SplitNavigator/createSplitNavigator.tsx | 111 +++++- 2 files changed, 258 insertions(+), 209 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index d66a502cc..fde9ce986 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -85,15 +85,6 @@ export type SplitNavigationState[]; /** @@ -107,13 +98,17 @@ export type SplitNavigationState | NavigationRoute)[]; + routes: NavigationRoute[]; /** * Whether it's splitted now or not */ @@ -123,6 +118,8 @@ export type SplitNavigationState( state: SplitNavigationState, ): SplitNavigationState { + return state; + // TODO const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); const mainRoute = state.routes[mainRouteIndex]; const currentRoute = state.routes[state.index]; @@ -191,7 +188,9 @@ function fold( function unfold( state: SplitNavigationState, ): SplitNavigationState { - const mainRouteIndex = state.tabRouteNames.indexOf(MAIN_SCREEN_NAME); + return state; + // TODO + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); const mainRoute = state.routes[mainRouteIndex] as MainRoute; const currentRoute = state.routes[state.index]; @@ -214,7 +213,7 @@ function unfold( // TODO history: [], routes: state.routeNames.map(name => { - let routeForNameIndex = state.tabRouteNames.indexOf(name); + let routeForNameIndex = state.routeNames.indexOf(name); let routeForName: typeof state.routes[0] | null = null; if (routeForNameIndex > -1) { routeForName = state.routes[routeForNameIndex]; @@ -243,9 +242,11 @@ function applyTabNavigateActionToState( if (action.type !== 'NAVIGATE') { return null; } + const history = state.history.filter(r => r !== index).concat([index]); return { ...state, index, + history, routes: action.payload.params == null ? state.routes @@ -296,22 +297,23 @@ class SplitUnfoldedRouter { * `main` is kind of special, as it renders on the left column. */ getInitialState({ routeNames, routeParamList }: RouterConfigOptions): SplitNavigationState { - const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; - const index = - // TODO: do we really need to check for includes, isn't it get -1 another way? - initialRouteName != null && routeNames.includes(initialRouteName) - ? routeNames.indexOf(initialRouteName) - : 0; + 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; + } + return { key: `split-${nanoid()}`, index, routeNames, - tabRouteNames, - stackRouteNames, stale: false, type: 'split', isSplitted, - history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], + history, routes: routeNames.map(name => { if (name === MAIN_SCREEN_NAME) { return { @@ -332,8 +334,8 @@ class SplitUnfoldedRouter { getRehydratedState( partialState: PartialState>, { routeNames, routeParamList }: RouterConfigOptions, - ): SplitNavigationState { - const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; + ): SplitNavigationState { + const { initialRouteName, isSplitted } = this.options; const routes = routeNames.map(name => { const route = partialState.routes?.find(r => r.name === name) ?? null; @@ -348,38 +350,30 @@ class SplitUnfoldedRouter { }; }); - let index = 0; + 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 provided route as initial - index = routeNames.indexOf(activeRouteName); - } else { - /** - * Sth went wrong and provided route wasn't found - * TODO: Maybe it's a good place to redirect to 404 - * - * TODO: not sure about main though - */ - index = routeNames.indexOf(initialRouteName || MAIN_SCREEN_NAME); + // set an index for a regular tab route + index = activeRouteIndex; + history[0] = activeRouteIndex; + } else if (initialRouteName != null) { + index = routeNames.indexOf(initialRouteName); + history[0] = index; } - } else { - // set initial otherwise - // TODO: not sure about main though - index = routeNames.indexOf(initialRouteName || MAIN_SCREEN_NAME); } return { key: `split-${nanoid()}`, index, routeNames, - tabRouteNames, - stackRouteNames, stale: false, type: 'split', isSplitted, - history: initialRouteName != null ? [initialRouteName] : [routeNames[0]], - // @ts-ignore + history, routes, }; } @@ -394,16 +388,22 @@ class SplitUnfoldedRouter { return state; } if (action.type === 'GO_BACK') { - // TODO - return null; + if (state.history.length < 2) { + return null; + } + const prevRouteIndex = state.history[state.history.length - 2]; + return applyTabNavigateActionToState(state, action, options, prevRouteIndex); } if (action.type === 'NAVIGATE') { - const { name } = action.payload; - // TODO: should we handle a key? + 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; } - // In unfolded mode we always should apply tab navigation const index = state.routeNames.indexOf(name); return applyTabNavigateActionToState(state, action, options, index); } @@ -432,9 +432,9 @@ class SplitFoldedRouter { * from main screen with stack navigation. */ getInitialState({ routeNames, routeParamList }: RouterConfigOptions): SplitNavigationState { - const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; + const { initialRouteName, stackRouteNames, isSplitted } = this.options; /** - * First challenge is to find index for initialRouteName - + * 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. @@ -443,179 +443,102 @@ class SplitFoldedRouter { * going to be initial, when in reality it should be `main`, however in splitted mode * it's a correct behaviour */ - let index = 0; - let stackIndex = 0; - let history = [MAIN_SCREEN_NAME]; - if (initialRouteName == null) { - // TODO: no-op? - } else if (tabRouteNames.includes(initialRouteName)) { - index = tabRouteNames.indexOf(initialRouteName); - history = [initialRouteName]; - } else { - // it's a route for stack - index = tabRouteNames.indexOf(MAIN_SCREEN_NAME); - // It's 1 as nested state will consist of two items: - // 0. main screen - // 1. initial screen, that is going to be active - stackIndex = 1; - history = [MAIN_SCREEN_NAME]; + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = 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); + nestedStack.push(nestedStackRouteNameIndex); + } else { + // It's a route for tab navigation + index = routeNames.indexOf(initialRouteName); + history[0] = index; + } } return { key: `split-${nanoid()}`, index, routeNames, - tabRouteNames, - stackRouteNames, stale: false, type: 'split', + isSplitted, history, - routes: tabRouteNames.map(name => { - if (name === MAIN_SCREEN_NAME) { - /** - * Second challenge is to create a correct state - * for nested stack navigation here - */ - const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; - const mainStackRoutes = [ - { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: routeParamList[MAIN_SCREEN_NAME], - }, - ]; - // It means that initial route have to be in nested stack - if (initialRouteName != null && stackIndex > 0) { - mainStackRoutes.push({ - name: initialRouteName, - key: `${initialRouteName}-${nanoid()}`, - params: routeParamList[initialRouteName], - }); - } - return { - name: MAIN_SCREEN_NAME, - key: `${name}-${nanoid()}`, - params: routeParamList[name], - state: { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: stackIndex, - routeNames: mainStackRouteNames, - routes: mainStackRoutes, - }, - }; - } + nestedStack, + routes: routeNames.map(name => { return { name, key: `${name}-${nanoid()}`, params: routeParamList[name], }; }), - isSplitted, }; } getRehydratedState( partialState: PartialState>, { routeNames, routeParamList }: RouterConfigOptions, - ): SplitNavigationState { + ): SplitNavigationState { const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; - let rootIndex = 0; - let stackIndex = 0; + const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); + let index = mainRouteIndex; + const history = [mainRouteIndex]; + const nestedStack = [mainRouteIndex]; + const activeRouteIndex = partialState?.index ?? 0; const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; - let history = [MAIN_SCREEN_NAME]; if (activeRouteName != null) { if (tabRouteNames.includes(activeRouteName)) { // set an index for a regular tab route - rootIndex = tabRouteNames.indexOf(activeRouteName); - history = [activeRouteName]; + index = activeRouteIndex; + history[0] = activeRouteIndex; } else if (stackRouteNames.includes(activeRouteName)) { - // set it as a main and then set index for nested stack - rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); - stackIndex = 1; - // history is already set + // leave it as a main and then set index for nested stack + const nestedStackRouteNameIndex = routeNames.indexOf(activeRouteName); + nestedStack.push(nestedStackRouteNameIndex); } else if (initialRouteName) { // nothing was found in known routes // TODO: Maybe it's a good place to redirect to 404 // tring to find initial route - if (tabRouteNames.includes(initialRouteName)) { - rootIndex = tabRouteNames.indexOf(initialRouteName); - history = [initialRouteName]; + if (stackRouteNames.includes(initialRouteName)) { + // It's a route for nested stack + const nestedStackRouteNameIndex = routeNames.indexOf(initialRouteName); + nestedStack.push(nestedStackRouteNameIndex); } else { - // it's a route for stack - rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); - stackIndex = 1; - // history is already set + // It's a route for tab navigation + index = routeNames.indexOf(initialRouteName); + history[0] = index; } - } else { - // at last if we found nothing at all, trying to set the main - rootIndex = tabRouteNames.indexOf(MAIN_SCREEN_NAME); - // history is already set } } - const routes = tabRouteNames.map(name => { - const route = partialState.routes?.find(r => r.name === name); - if (name === MAIN_SCREEN_NAME) { - const mainStackRouteNames = [MAIN_SCREEN_NAME, ...stackRouteNames]; - const mainStackRoutes = [ - { - name: MAIN_SCREEN_NAME, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: routeParamList[MAIN_SCREEN_NAME], - }, - ]; - if (activeRouteName != null && stackIndex > 0) { - const activeRoute = partialState.routes?.find(r => r.name === activeRouteName); - mainStackRoutes.push({ - ...activeRoute, - name: activeRouteName, - key: `${activeRouteName}-${nanoid()}`, - params: - routeParamList[activeRouteName] != null - ? { ...routeParamList[activeRouteName], ...route?.params } - : route?.params, - }); - } - return { - name: MAIN_SCREEN_NAME, - key: `${name}-${nanoid()}`, - params: routeParamList[name], - state: { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: stackIndex, - routeNames: mainStackRouteNames, - routes: mainStackRoutes, - }, - }; - } - return { - ...route, - name, - key: route && route.name === name && route.key ? route.key : `${name}-${nanoid()}`, - params: - routeParamList[name] != null - ? { ...routeParamList[name], ...route?.params } - : route?.params, - }; - }); - return { key: `split-${nanoid()}`, - index: rootIndex, + index, routeNames, - tabRouteNames, - stackRouteNames, stale: false, type: 'split', isSplitted, history, - // @ts-ignore - routes, + nestedStack, + routes: routeNames.map(name => { + 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, + }; + }), }; } @@ -624,48 +547,69 @@ class SplitFoldedRouter { action: CommonNavigationAction | SplitActionType, options: RouterConfigOptions, ): SplitNavigationState | null { + const { tabRouteNames } = this.options; if (action.type === 'RESET_TO_INITIAL') { // TODO return state; } if (action.type === 'GO_BACK') { - // TODO - return state; + // In folded mode that shouldn't be a case + // Suppress TS error + if (state.nestedStack == null) { + return null; + } + + const currentTabRoute = state.routes[state.index]; + // 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) { + return { + ...state, + nestedStack: state.nestedStack.slice(0, state.nestedStack.length - 2), + }; + } + // If it isn't main, then do the same thing as in unfolded router + // TODO: copy/paste from unfolded + if (state.history.length < 2) { + return null; + } + const prevRouteIndex = state.history[state.history.length - 2]; + return applyTabNavigateActionToState(state, action, options, prevRouteIndex); } if (action.type === 'NAVIGATE') { - const { name } = action.payload; - // TODO: should we handle a key? + // In folded mode that shouldn't be a case + // Suppress TS error + if (state.nestedStack == null) { + return null; + } + + 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; } - if (state.tabRouteNames.includes(name)) { + 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 - const index = state.tabRouteNames.indexOf(name); return applyTabNavigateActionToState(state, action, options, index); } // For stack routes in shrinked mode we have to call // stack router method on nested navigation in the main route - return { - ...state, - // @ts-ignore - routes: state.routes.map(route => { - if (route.name !== MAIN_SCREEN_NAME) { - return route; - } - if (route.state == null) { - return route; - } - return { - ...route, - state: this.stackRouter.getStateForAction( - route.state as any, - action, - options, - ), - }; - }), - }; + return applyTabNavigateActionToState( + { + ...state, + nestedStack: state.nestedStack + .filter(routeIndex => routeIndex !== index) + .concat([index]), + }, + action, + options, + state.routeNames.indexOf(MAIN_SCREEN_NAME), + ); } return null; } diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index f533a599c..ced981d39 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -19,6 +19,8 @@ import { } 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 { NativeStackView } from 'react-native-screens/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { UIImage } from '@tonlabs/uikit.media'; @@ -278,16 +280,21 @@ export function SplitNavigator({ main: styles.main, detail: styles.detail, }; + // TODO: optimise me! const { tabRouteNames, stackRouteNames } = React.Children.toArray(children).reduce<{ tabRouteNames: string[]; stackRouteNames: string[]; }>( (acc, child) => { if (React.isValidElement(child)) { - if ('tabBarActiveIcon' in child.props || 'tabBarIconLottieSource' in child.props) { + if ( + 'tabBarActiveIcon' in child.props || + 'tabBarIconLottieSource' in child.props || + child.props.name === MAIN_SCREEN_NAME + ) { acc.tabRouteNames.push(child.props.name); } else { - acc.tabRouteNames.push(child.props.name); + acc.stackRouteNames.push(child.props.name); } } return acc; @@ -316,6 +323,8 @@ export function SplitNavigator({ stackRouteNames, isSplitted, }); + console.log(state); + console.log(descriptors); React.useEffect(() => { navigation.dispatch( @@ -335,6 +344,7 @@ export function 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, @@ -342,7 +352,6 @@ export function SplitNavigator({ if (mainRoute == null) { throw new Error(`You should provide ${MAIN_SCREEN_NAME} screen!`); } - const tabBarIcons = Object.keys(descriptors).reduce< Record >((acc, key) => { @@ -451,6 +460,13 @@ export function SplitNavigator({ return acc; }, {}); + const stackDescriptors = state.routes.reduce((acc, route, index) => { + const descriptor = descriptors[route.key]; + if (state.nestedStack && state.nestedStack.includes(index)) { + acc[route.key] = descriptor; + } + return acc; + }, {}); return ( @@ -474,6 +490,95 @@ export function SplitNavigator({ return null; } + if (route.name === MAIN_SCREEN_NAME) { + if (doesSupportNative) { + return ( + + + + + + stackDescriptors[r.key] == + null, + ), + }} + navigation={navigation} + // @ts-ignore + descriptors={stackDescriptors} + /> + + + + + ); + } + + return ( + + + + + {/* @ts-ignore */} + + stackDescriptors[r.key] == null, + ), + }} + navigation={navigation} + // @ts-ignore + descriptors={stackDescriptors} + /> + + + + + ); + } + + if (stackDescriptors[route.key] != null) { + return null; + } + return ( Date: Fri, 12 Nov 2021 14:40:06 +0300 Subject: [PATCH 05/20] Proper linking and history for web on folded mode --- .../src/SplitNavigator/SplitRouter.ts | 252 ++++++++---------- .../SplitNavigator/createSplitNavigator.tsx | 36 +-- 2 files changed, 134 insertions(+), 154 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index fde9ce986..529e393ee 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -79,8 +79,16 @@ export type SplitNavigationState( }; } -function applyTabNavigateActionToState( +function applyTabNavigateActionToRoutes( state: SplitNavigationState, action: CommonNavigationAction, options: RouterConfigOptions, index: number, -): SplitNavigationState | null { +): SplitNavigationState['routes'] { if (action.type !== 'NAVIGATE') { - return null; + return state.routes; } - const history = state.history.filter(r => r !== index).concat([index]); - return { - ...state, - index, - history, - routes: - 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; - }), - }; + 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; + }); } class SplitUnfoldedRouter { @@ -309,6 +310,7 @@ class SplitUnfoldedRouter { return { key: `split-${nanoid()}`, index, + tabIndex: index, routeNames, stale: false, type: 'split', @@ -369,6 +371,7 @@ class SplitUnfoldedRouter { return { key: `split-${nanoid()}`, index, + tabIndex: index, routeNames, stale: false, type: 'split', @@ -392,7 +395,13 @@ class SplitUnfoldedRouter { return null; } const prevRouteIndex = state.history[state.history.length - 2]; - return applyTabNavigateActionToState(state, action, options, prevRouteIndex); + 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; @@ -405,7 +414,13 @@ class SplitUnfoldedRouter { return null; } const index = state.routeNames.indexOf(name); - return applyTabNavigateActionToState(state, action, options, index); + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + routes: applyTabNavigateActionToRoutes(state, action, options, index), + }; } return null; } @@ -445,16 +460,21 @@ class SplitFoldedRouter { */ 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; } } @@ -462,6 +482,7 @@ class SplitFoldedRouter { return { key: `split-${nanoid()}`, index, + tabIndex, routeNames, stale: false, type: 'split', @@ -486,19 +507,23 @@ class SplitFoldedRouter { const mainRouteIndex = routeNames.indexOf(MAIN_SCREEN_NAME); let index = mainRouteIndex; + let tabIndex = mainRouteIndex; const history = [mainRouteIndex]; const nestedStack = [mainRouteIndex]; - const activeRouteIndex = partialState?.index ?? 0; const activeRouteName = partialState.routes[partialState?.index ?? 0]?.name; if (activeRouteName != null) { if (tabRouteNames.includes(activeRouteName)) { // set an index for a regular tab route - index = activeRouteIndex; - history[0] = activeRouteIndex; + 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 // TODO: Maybe it's a good place to redirect to 404 @@ -506,10 +531,14 @@ class SplitFoldedRouter { 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; } } @@ -518,6 +547,7 @@ class SplitFoldedRouter { return { key: `split-${nanoid()}`, index, + tabIndex, routeNames, stale: false, type: 'split', @@ -559,12 +589,15 @@ class SplitFoldedRouter { return null; } - const currentTabRoute = state.routes[state.index]; + 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: state.nestedStack.slice(0, state.nestedStack.length - 2), + nestedStack, + history: state.history.slice(0, history.length - 2), + index: nestedStack[nestedStack.length - 1], }; } // If it isn't main, then do the same thing as in unfolded router @@ -573,7 +606,13 @@ class SplitFoldedRouter { return null; } const prevRouteIndex = state.history[state.history.length - 2]; - return applyTabNavigateActionToState(state, action, options, prevRouteIndex); + 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 @@ -595,21 +634,23 @@ class SplitFoldedRouter { if (tabRouteNames.includes(name)) { // In shrinked mode need to apply simple tab navigation // only for routes that are in the bar - return applyTabNavigateActionToState(state, action, options, index); - } - // For stack routes in shrinked mode we have to call - // stack router method on nested navigation in the main route - return applyTabNavigateActionToState( - { + return { ...state, - nestedStack: state.nestedStack - .filter(routeIndex => routeIndex !== index) - .concat([index]), - }, - action, - options, - state.routeNames.indexOf(MAIN_SCREEN_NAME), - ); + 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; } @@ -631,6 +672,7 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { type: 'split', getInitialState(options: RouterConfigOptions) { + console.log('getInitialState'); return (isSplitted ? unfoldedRouter : foldedRouter).getInitialState(options); }, @@ -657,11 +699,13 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return state; } + console.log('getRehydratedState', state); return (isSplitted ? unfoldedRouter : foldedRouter).getRehydratedState(state, options); }, // TODO getStateForRouteNamesChange(state /* , options */) { + console.log('getStateForRouteNamesChange'); // const newState: SplitNavigationState = isSplitted // ? (tabRouter.getStateForRouteNamesChange(state as any, options) as any) // : stackRouter.getStateForRouteNamesChange(state as any, options); @@ -672,6 +716,7 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { // TODO getStateForRouteFocus(state /* , key */) { + console.log('getStateForRouteFocus'); // const newState: SplitNavigationState = isSplitted // ? (tabRouter.getStateForRouteFocus(state as any, key) as any) // : stackRouter.getStateForRouteFocus(state as any, key); @@ -681,6 +726,7 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { }, getStateForAction(state: SplitNavigationState, action, options) { + console.log('getStateForAction', action); if (action.type === 'SET_SPLITTED') { ({ isSplitted } = action.payload); @@ -699,6 +745,9 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { } return unfold(state); } + if (action.type === 'SET_PARAMS' || action.type === 'RESET') { + return BaseRouter.getStateForAction(state, action); + } return (state.isSplitted ? unfoldedRouter : foldedRouter).getStateForAction( state, action, @@ -707,6 +756,7 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { }, shouldActionChangeFocus(/* action */) { + console.log('shouldActionChangeFocus'); // TODO // return tabRouter.shouldActionChangeFocus(action); return false; @@ -715,75 +765,3 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return router; } - -// splitted=true -// { -// stale: false, -// type: 'split', -// key: `split-${nanoid()}`, -// index: 0, // initial -// routeNames: ['main', 'buttons', 'carousel'], -// // history, -// routes: [ -// { -// key: `main-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// // for stack -// { -// key: `buttons-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// // for tab -// { -// key: `carousel-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// ], -// } - -// splitted=false -// { -// stale: false, -// type: 'split', -// key: `split-${nanoid()}`, -// index: 0, // initial -// routeNames: ['main', 'carousel'], -// // history, -// routes: [ -// // main -// { -// key: `main-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// state: { -// stale: false, -// type: 'stack', -// key: `stack-${nanoid()}`, -// index: 0, -// routeNames: ['main', 'buttons'], -// routes: [ -// { -// key: `main-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// { -// key: `buttons-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// ], -// } -// }, -// // for tab -// { -// key: `carousel-${nanoid()}`, -// // name, -// // params: routeParamList[name], -// }, -// ], -// } diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index ced981d39..023592a18 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -390,7 +390,7 @@ export function SplitNavigator({ @@ -403,7 +403,7 @@ export function SplitNavigator({ > {state.routes.map((route, index) => { const descriptor = descriptors[route.key]; - const isFocused = state.index === index; + const isFocused = state.tabIndex === index; // Do not render main route if (route.key === mainRoute.key) { @@ -480,7 +480,7 @@ export function SplitNavigator({ > {state.routes.map((route, index) => { const descriptor = descriptors[route.key]; - const isFocused = state.index === index; + const isFocused = state.tabIndex === index; // isFocused check is important here // as we can try to render a screen before it was put @@ -491,6 +491,9 @@ export function SplitNavigator({ } if (route.name === MAIN_SCREEN_NAME) { + if (state.nestedStack == null) { + return null; + } if (doesSupportNative) { return ( - stackDescriptors[r.key] == - null, + routes: state.nestedStack.map( + routeIndex => { + return state.routes[ + routeIndex + ]; + }, ), }} navigation={navigation} @@ -555,13 +558,12 @@ export function SplitNavigator({ 'split', 'stack', ), - index: state.nestedStack - ? state.nestedStack.length - 1 - : 0, + index: state.nestedStack.length - 1, routeNames: stackRouteNames, - routes: state.routes.filter( - r => - stackDescriptors[r.key] == null, + routes: state.nestedStack.map( + routeIndex => { + return state.routes[routeIndex]; + }, ), }} navigation={navigation} @@ -597,7 +599,7 @@ export function SplitNavigator({ From 3a7b86b50bff2e5296f8f61761df35db1db1f231 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Fri, 12 Nov 2021 14:52:03 +0300 Subject: [PATCH 06/20] Support getStateForRouteFocus --- .../src/SplitNavigator/SplitRouter.ts | 67 ++++++++++++++----- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 529e393ee..d225bc787 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -381,6 +381,24 @@ class SplitUnfoldedRouter { }; } + getStateForRouteFocus( + state: SplitNavigationState, + key: string, + ): SplitNavigationState { + const index = state.routes.findIndex(r => r.key === key); + + if (index === -1 || index === state.index) { + return state; + } + + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + }; + } + getStateForAction( state: SplitNavigationState, action: CommonNavigationAction | SplitActionType, @@ -572,6 +590,37 @@ class SplitFoldedRouter { }; } + 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]), + }; + } + + return { + ...state, + index, + tabIndex: index, + history: state.history.filter(r => r !== index).concat([index]), + }; + } + getStateForAction( state: SplitNavigationState, action: CommonNavigationAction | SplitActionType, @@ -703,26 +752,14 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return (isSplitted ? unfoldedRouter : foldedRouter).getRehydratedState(state, options); }, - // TODO getStateForRouteNamesChange(state /* , options */) { - console.log('getStateForRouteNamesChange'); - // 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 }); + console.warn("Dynamic routes isn't supported yet in SplitRouter"); return state; }, // TODO - getStateForRouteFocus(state /* , key */) { - console.log('getStateForRouteFocus'); - // 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 state; + getStateForRouteFocus(state, key) { + return (isSplitted ? unfoldedRouter : foldedRouter).getStateForRouteFocus(state, key); }, getStateForAction(state: SplitNavigationState, action, options) { From 9d1c7db3adc5d05f42ed4abd02f22033c6ecfd72 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Fri, 12 Nov 2021 18:44:00 +0300 Subject: [PATCH 07/20] Made tabbar --- Example/src/App.tsx | 22 +- .../src/SplitNavigator/SplitRouter.ts | 1 - .../SplitNavigator/createSplitNavigator.tsx | 309 +++++++++++------- 3 files changed, 207 insertions(+), 125 deletions(-) diff --git a/Example/src/App.tsx b/Example/src/App.tsx index 9109a5355..547b48b24 100644 --- a/Example/src/App.tsx +++ b/Example/src/App.tsx @@ -37,8 +37,10 @@ import { UILargeTitleHeader, UISearchBarButton, } from '@tonlabs/uicast.bars'; -import { createSplitNavigator } from '@tonlabs/uicast.split-navigator'; +// import { createSplitNavigator } from '@tonlabs/uicast.split-navigator'; import { ScrollView } from '@tonlabs/uikit.scrolls'; +import { UIAssets } from '@tonlabs/uikit.assets'; +import { createSplitNavigator } from '../../casts/splitNavigator/src'; import { ButtonsScreen } from './screens/Buttons'; import { Checkbox } from './screens/Checkbox'; @@ -338,7 +340,14 @@ const App = () => { - + @@ -347,7 +356,14 @@ const App = () => { - + diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index d225bc787..861cc06ed 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -721,7 +721,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { type: 'split', getInitialState(options: RouterConfigOptions) { - console.log('getInitialState'); return (isSplitted ? unfoldedRouter : foldedRouter).getInitialState(options); }, diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 023592a18..acdb0291e 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -15,15 +15,29 @@ import { NavigationHelpersContext, useNavigationBuilder, createNavigatorFactory, - useTheme, + useTheme as useNavTheme, } 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 { NativeStackView } from 'react-native-screens/native-stack'; - import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { UIImage } from '@tonlabs/uikit.media'; +import { useTheme, ColorVariants, UIBackgroundView } from '@tonlabs/uikit.themes'; +import { hapticSelection } from '@tonlabs/uikit.controls'; +import { + GestureEvent, + NativeViewGestureHandlerPayload, + RawButton as GHRawButton, +} from 'react-native-gesture-handler'; +import ReAnimated, { + runOnJS, + useAnimatedGestureHandler, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; import { SplitRouter, SplitActions, MAIN_SCREEN_NAME, SplitActionHelpers } from './SplitRouter'; @@ -36,7 +50,7 @@ export const NestedInSplitContext = React.createContext<{ const getIsSplitted = ({ width }: { width: number }, mainWidth: number) => width > mainWidth; function SceneContent({ isFocused, children }: { isFocused: boolean; children: React.ReactNode }) { - const { colors } = useTheme(); + const { colors } = useNavTheme(); return ( ( - function LottieIconWrapper({ defaultActiveState, source }: LottieIconViewProps, ref) { - const progress = React.useRef(new Animated.Value(defaultActiveState ? 1 : 0)).current; - React.useImperativeHandle(ref, () => ({ - activate() { - /** - * TODO: maybe linear is better to keep it in sync with the dot? - */ - Animated.spring(progress, { - toValue: 1, - useNativeDriver: true, - }); - }, - disable() { - Animated.spring(progress, { - toValue: 0, - useNativeDriver: true, - }); - }, - })); +function LottieIconView({ activeState, source }: LottieIconViewProps) { + const progress = React.useRef(new Animated.Value(activeState ? 1 : 0)).current; + // React.useImperativeHandle(ref, () => ({ + // activate() { + // /** + // * TODO: maybe linear is better to keep it in sync with the dot? + // */ + // Animated.spring(progress, { + // toValue: 1, + // useNativeDriver: true, + // }); + // }, + // disable() { + // Animated.spring(progress, { + // toValue: 0, + // useNativeDriver: true, + // }); + // }, + // })); - return ( - - ); - }, -); + return ( + + ); +} type ImageIconViewProps = { - defaultActiveState: boolean; + activeState: boolean; activeSource: ImageSourcePropType; disabledSource: ImageSourcePropType; }; -const ImageIconView = React.forwardRef( - function ImageIconView( - { defaultActiveState, activeSource, disabledSource }: ImageIconViewProps, - ref, - ) { - const [active, setActive] = React.useState(defaultActiveState); - React.useImperativeHandle(ref, () => ({ - activate() { - setActive(true); - }, - disable() { - setActive(false); - }, - })); +function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconViewProps) { + return ( + + ); +} - return ( - - ); - }, +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} + + ); +} +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) * 64 - 32); + React.useImperativeHandle(ref, () => ({ + moveTo(index: number) { + position.value = withSpring((index + 1) * 64 - 32, { overshootClamping: true }); + }, + })); + + const style = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: position.value, + }, + ], + }; + }); + return ( + + ); + }, + ), ); function SplitBottomTabBar({ icons, - setTabBarHeight, activeKey, + onPress, }: { icons: Record; - setTabBarHeight: (height: number) => void; activeKey: string; + onPress: (key: string) => void; }) { + const theme = useTheme(); const insets = useSafeAreaInsets(); - React.useEffect(() => { - setTabBarHeight(Math.max(insets?.bottom, 32 /* TODO */) + 64 /* TODO */); - }, [insets?.bottom, setTabBarHeight]); - - const iconsRefs = React.useRef>>({}); - + const dotRef = React.useRef(null); const prevActiveKey = React.useRef(activeKey); + const initialDotIndex = React.useRef(-1); + if (initialDotIndex.current === -1) { + const iconsArr = Object.keys(icons); + for (let i = 0; i < iconsArr.length; i += 1) { + if (iconsArr[i] === activeKey) { + initialDotIndex.current = i; + break; + } + } + } React.useEffect(() => { if (activeKey === prevActiveKey.current) { return; } prevActiveKey.current = activeKey; - iconsRefs.current[prevActiveKey.current]?.current?.disable(); - iconsRefs.current[activeKey]?.current?.activate(); - // TODO: move the dot - }, [activeKey]); + const iconsArr = Object.keys(icons); + for (let i = 0; i < iconsArr.length; i += 1) { + if (iconsArr[i] === activeKey) { + dotRef.current?.moveTo(i); + break; + } + } + }, [activeKey, icons]); /** * Do not show tab bar when there're only @@ -194,7 +272,7 @@ function SplitBottomTabBar({ } return ( - { - setTabBarHeight(height); - }} > - + {Object.keys(icons).map(key => { const icon = icons[key]; - if (iconsRefs.current[key] == null) { - iconsRefs.current[key] = React.createRef(); - } if ('tabBarIconLottieSource' in icon) { return ( - + - + ); } return ( - + - + ); })} + - + ); } @@ -288,9 +350,10 @@ export function SplitNavigator({ (acc, child) => { if (React.isValidElement(child)) { if ( - 'tabBarActiveIcon' in child.props || - 'tabBarIconLottieSource' in child.props || - child.props.name === MAIN_SCREEN_NAME + child.props.name === MAIN_SCREEN_NAME || + (child.props.options != null && + ('tabBarActiveIcon' in child.props.options || + 'tabBarIconLottieSource' in child.props.options)) ) { acc.tabRouteNames.push(child.props.name); } else { @@ -324,7 +387,6 @@ export function SplitNavigator({ isSplitted, }); console.log(state); - console.log(descriptors); React.useEffect(() => { navigation.dispatch( @@ -340,7 +402,8 @@ export function SplitNavigator({ } }, [state, loaded]); - const [tabBarHeight, setTabBarHeight] = React.useState(0); + const insets = useSafeAreaInsets(); + const tabBarHeight = Math.max(insets?.bottom, 32 /* TODO */) + 64; /* TODO */ // Access it from the state to re-render a container // only when router has processed SET_SPLITTED action @@ -389,8 +452,10 @@ export function SplitNavigator({ { + navigation.navigate({ key }); + }} /> @@ -598,8 +663,10 @@ export function SplitNavigator({ { + navigation.navigate({ key }); + }} /> From 009e3efd084ae2cc16f838a9461e07db3353d5a5 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Mon, 15 Nov 2021 12:36:53 +0300 Subject: [PATCH 08/20] More features --- Example/src/App.tsx | 18 +- .../src/SplitNavigator/SplitBottomTabBar.tsx | 279 ++++++++++++++++++ .../src/SplitNavigator/SplitRouter.ts | 195 ++++++------ .../SplitNavigator/createSplitNavigator.tsx | 269 +---------------- 4 files changed, 381 insertions(+), 380 deletions(-) create mode 100644 casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx diff --git a/Example/src/App.tsx b/Example/src/App.tsx index 547b48b24..ffc0b6353 100644 --- a/Example/src/App.tsx +++ b/Example/src/App.tsx @@ -40,7 +40,7 @@ import { // import { createSplitNavigator } from '@tonlabs/uicast.split-navigator'; import { ScrollView } from '@tonlabs/uikit.scrolls'; import { UIAssets } from '@tonlabs/uikit.assets'; -import { createSplitNavigator } from '../../casts/splitNavigator/src'; +import { createSplitNavigator, useSplitTabBarHeight } from '../../casts/splitNavigator/src'; import { ButtonsScreen } from './screens/Buttons'; import { Checkbox } from './screens/Checkbox'; @@ -82,6 +82,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 ( @@ -142,7 +143,11 @@ const Main = ({ navigation }: { navigation: any }) => { }} - + { tabBarDisabledIcon: UIAssets.icons.ui.buttonStickerDisabled, }} /> - + diff --git a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx new file mode 100644 index 000000000..2861dd725 --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx @@ -0,0 +1,279 @@ +import * as React from 'react'; +import { View, Animated, ImageSourcePropType, ImageRequireSource } from 'react-native'; +import { + GestureEvent, + NativeViewGestureHandlerPayload, + RawButton as GHRawButton, +} 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, UIBackgroundView } from '@tonlabs/uikit.themes'; +import { hapticSelection } from '@tonlabs/uikit.controls'; + +export type SplitScreenTabBarIconOptions = + | { + tabBarActiveIcon: ImageSourcePropType; + tabBarDisabledIcon: ImageSourcePropType; + } + | { + tabBarIconLottieSource: ImageRequireSource; + }; + +/** + * TODO + */ +function LottieView(_props: { + source: ImageSourcePropType; + progress: Animated.Value; + style: StyleProp; +}) { + return null; +} + +type LottieIconViewProps = { + activeState: boolean; + source: ImageRequireSource; +}; +function LottieIconView({ activeState, source }: LottieIconViewProps) { + const progress = React.useRef(new Animated.Value(activeState ? 1 : 0)).current; + // React.useImperativeHandle(ref, () => ({ + // activate() { + // /** + // * TODO: maybe linear is better to keep it in sync with the dot? + // */ + // Animated.spring(progress, { + // toValue: 1, + // useNativeDriver: true, + // }); + // }, + // disable() { + // Animated.spring(progress, { + // toValue: 0, + // useNativeDriver: true, + // }); + // }, + // })); + + return ( + + ); +} + +type ImageIconViewProps = { + activeState: boolean; + activeSource: ImageSourcePropType; + disabledSource: ImageSourcePropType; +}; +function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconViewProps) { + return ( + + ); +} + +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} + + ); +} +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) * 64 - 32 - 4 / 2); + React.useImperativeHandle(ref, () => ({ + moveTo(index: number) { + position.value = withSpring((index + 1) * 64 - 32 - 4 / 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); + if (initialDotIndex.current === -1) { + const iconsArr = Object.keys(icons); + for (let i = 0; i < iconsArr.length; i += 1) { + if (iconsArr[i] === activeKey) { + initialDotIndex.current = i; + break; + } + } + } + React.useEffect(() => { + if (activeKey === prevActiveKey.current) { + return; + } + + prevActiveKey.current = activeKey; + const iconsArr = Object.keys(icons); + for (let i = 0; i < iconsArr.length; i += 1) { + if (iconsArr[i] === activeKey) { + dotRef.current?.moveTo(i); + break; + } + } + }, [activeKey, icons]); + const hasIconForActiveKey = React.useMemo(() => { + const iconsArr = Object.keys(icons); + for (let i = 0; i < iconsArr.length; i += 1) { + if (iconsArr[i] === activeKey) { + return true; + } + } + return false; + }, [icons, activeKey]); + + /** + * 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 ('tabBarIconLottieSource' in icon) { + return ( + + + + ); + } + return ( + + + + ); + })} + {hasIconForActiveKey && ( + + )} + + + ); +} diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 861cc06ed..65b8b03df 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -11,7 +11,6 @@ import type { PartialState, Route, Router, - StackNavigationState, RouterConfigOptions, } from '@react-navigation/core'; @@ -59,12 +58,6 @@ type SplitRouterCustomOptions = { export type SplitRouterOptions = DefaultRouterOptions> & SplitRouterCustomOptions; -type MainRoute = Route< - Extract, - ParamList[RouteName] -> & { - state: StackNavigationState; -}; type NavigationRoute = Route< Extract, ParamList[RouteName] @@ -72,6 +65,40 @@ type NavigationRoute; }; +/** + * 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. @@ -125,119 +152,42 @@ export type SplitNavigationState( state: SplitNavigationState, + stackRouteNames: string[], ): SplitNavigationState { - return state; - // TODO const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); - const mainRoute = state.routes[mainRouteIndex]; - const currentRoute = state.routes[state.index]; - const currentRouteName = currentRoute.name; - const currentRouteIsForStack = state.stackRouteNames.includes(currentRouteName); + 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()}`, - index: currentRouteIsForStack - ? mainRouteIndex - : state.tabRouteNames.indexOf(currentRouteName), - // TODO - history: [], - // @ts-ignore - routes: state.tabRouteNames.map(name => { - if (name === MAIN_SCREEN_NAME) { - const mainStackRouteNames = [MAIN_SCREEN_NAME, ...state.stackRouteNames]; - const mainStackRoutes: { - name: Extract; - key: string; - params: ParamList; - state?: StackNavigationState; - }[] = [ - { - name: MAIN_SCREEN_NAME as Extract, - key: `${MAIN_SCREEN_NAME}-${nanoid()}`, - params: mainRoute.params as any, - }, - ]; - const hasNestedActiveRoute = - state.index !== mainRouteIndex && currentRouteIsForStack; - if (hasNestedActiveRoute) { - mainStackRoutes.push({ - name: currentRouteName, - key: `${currentRouteName}-${nanoid()}`, - params: currentRoute.params as any, - state: currentRoute.state as any, - }); - } - return { - name: MAIN_SCREEN_NAME, - key: `${name}-${nanoid()}`, - params: state.routes[mainRouteIndex].params, - state: { - stale: false, - type: 'stack', - key: `stack-${nanoid()}`, - index: hasNestedActiveRoute ? 1 : 0, - routeNames: mainStackRouteNames, - routes: mainStackRoutes, - }, - }; - } - const routeForName = state.routes[state.routeNames.indexOf(name)]; - return { - name, - key: `${name}-${nanoid()}`, - params: routeForName == null ? undefined : routeForName.params, - state: routeForName == null ? undefined : routeForName.state, - }; - }), + tabIndex, + nestedStack, + isSplitted: false, }; } -function unfold( - state: SplitNavigationState, -): SplitNavigationState { - return state; - // TODO - const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); - const mainRoute = state.routes[mainRouteIndex] as MainRoute; - const currentRoute = state.routes[state.index]; - - let index = 0; - if (state.index === mainRouteIndex) { - if (mainRoute.state.index > 0) { - const currentNestedRoute = mainRoute.state.routes[mainRoute.state.index]; - index = state.routeNames.indexOf(currentNestedRoute.name); - } else { - index = state.routeNames.indexOf(MAIN_SCREEN_NAME); +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); } - } else { - index = state.routeNames.indexOf(currentRoute.name); } - return { ...state, key: `split-${nanoid()}`, index, - // TODO - history: [], - routes: state.routeNames.map(name => { - let routeForNameIndex = state.routeNames.indexOf(name); - let routeForName: typeof state.routes[0] | null = null; - if (routeForNameIndex > -1) { - routeForName = state.routes[routeForNameIndex]; - } else { - routeForNameIndex = mainRoute.state.routeNames.indexOf(name as any); - if (routeForNameIndex > -1) { - routeForName = mainRoute.state.routes[routeForNameIndex]; - } - } - return { - name, - key: `${name}-${nanoid()}`, - params: routeForName == null ? undefined : routeForName.params, - state: routeForName == null ? undefined : routeForName.state, - }; - }), + tabIndex, + history, + isSplitted: true, }; } @@ -405,8 +355,12 @@ class SplitUnfoldedRouter { options: RouterConfigOptions, ): SplitNavigationState | null { if (action.type === 'RESET_TO_INITIAL') { - // TODO - return state; + const mainRouteIndex = state.routeNames.indexOf(MAIN_SCREEN_NAME); + return { + ...state, + tabIndex: mainRouteIndex, + history: [mainRouteIndex], + }; } if (action.type === 'GO_BACK') { if (state.history.length < 2) { @@ -626,10 +580,23 @@ class SplitFoldedRouter { action: CommonNavigationAction | SplitActionType, options: RouterConfigOptions, ): SplitNavigationState | null { - const { tabRouteNames } = this.options; + const { tabRouteNames, stackRouteNames } = this.options; if (action.type === 'RESET_TO_INITIAL') { - // TODO - return state; + 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 @@ -771,15 +738,19 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return state; } + // TODO: what it's for? if (action.payload.initialRouteName) { foldedRouter.options.initialRouteName = action.payload.initialRouteName; unfoldedRouter.options.initialRouteName = action.payload.initialRouteName; } if (isSplitted) { - return fold(state); + return unfold( + state, + action.payload.initialRouteName || unfoldedRouter.options.initialRouteName, + ); } - return unfold(state); + return fold(state, routerOptions.stackRouteNames); } if (action.type === 'SET_PARAMS' || action.type === 'RESET') { return BaseRouter.getStateForAction(state, action); diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index acdb0291e..02db88472 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -6,9 +6,6 @@ import { ViewStyle, Platform, StyleProp, - Animated, - ImageSourcePropType, - ImageRequireSource, } from 'react-native'; import type { EventMapBase, NavigationState } from '@react-navigation/core'; import { @@ -22,26 +19,12 @@ import { screensEnabled, ScreenContainer } from 'react-native-screens'; import { StackView } from '@react-navigation/stack'; import { NativeStackView } from 'react-native-screens/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { UIImage } from '@tonlabs/uikit.media'; -import { useTheme, ColorVariants, UIBackgroundView } from '@tonlabs/uikit.themes'; -import { hapticSelection } from '@tonlabs/uikit.controls'; -import { - GestureEvent, - NativeViewGestureHandlerPayload, - RawButton as GHRawButton, -} from 'react-native-gesture-handler'; -import ReAnimated, { - runOnJS, - useAnimatedGestureHandler, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; import { SplitRouter, SplitActions, MAIN_SCREEN_NAME, SplitActionHelpers } from './SplitRouter'; import type { SplitNavigationState, SplitRouterOptions } from './SplitRouter'; +import { SplitBottomTabBar, SplitScreenTabBarIconOptions } from './SplitBottomTabBar'; export const NestedInSplitContext = React.createContext<{ isSplitted: boolean; @@ -64,6 +47,9 @@ function SceneContent({ isFocused, children }: { isFocused: boolean; children: R } const SplitTabBarHeightContext = React.createContext(0); +export function useSplitTabBarHeight() { + return React.useContext(SplitTabBarHeightContext); +} type SplitNavigatorProps = { children?: React.ReactNode; @@ -77,255 +63,8 @@ type SplitNavigatorProps = { }; } & SplitRouterOptions; }; - -type SplitScreenTabBarIconOptions = - | { - tabBarActiveIcon: ImageSourcePropType; - tabBarDisabledIcon: ImageSourcePropType; - } - | { - tabBarIconLottieSource: ImageRequireSource; - }; type SplitScreenOptions = SplitScreenTabBarIconOptions | Record; -/** - * TODO - */ -function LottieView(_props: { - source: ImageSourcePropType; - progress: Animated.Value; - style: StyleProp; -}) { - return null; -} - -type LottieIconViewProps = { - activeState: boolean; - source: ImageRequireSource; -}; -function LottieIconView({ activeState, source }: LottieIconViewProps) { - const progress = React.useRef(new Animated.Value(activeState ? 1 : 0)).current; - // React.useImperativeHandle(ref, () => ({ - // activate() { - // /** - // * TODO: maybe linear is better to keep it in sync with the dot? - // */ - // Animated.spring(progress, { - // toValue: 1, - // useNativeDriver: true, - // }); - // }, - // disable() { - // Animated.spring(progress, { - // toValue: 0, - // useNativeDriver: true, - // }); - // }, - // })); - - return ( - - ); -} - -type ImageIconViewProps = { - activeState: boolean; - activeSource: ImageSourcePropType; - disabledSource: ImageSourcePropType; -}; -function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconViewProps) { - return ( - - ); -} - -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} - - ); -} -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) * 64 - 32); - React.useImperativeHandle(ref, () => ({ - moveTo(index: number) { - position.value = withSpring((index + 1) * 64 - 32, { overshootClamping: true }); - }, - })); - - const style = useAnimatedStyle(() => { - return { - transform: [ - { - translateX: position.value, - }, - ], - }; - }); - return ( - - ); - }, - ), -); - -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); - if (initialDotIndex.current === -1) { - const iconsArr = Object.keys(icons); - for (let i = 0; i < iconsArr.length; i += 1) { - if (iconsArr[i] === activeKey) { - initialDotIndex.current = i; - break; - } - } - } - React.useEffect(() => { - if (activeKey === prevActiveKey.current) { - return; - } - - prevActiveKey.current = activeKey; - const iconsArr = Object.keys(icons); - for (let i = 0; i < iconsArr.length; i += 1) { - if (iconsArr[i] === activeKey) { - dotRef.current?.moveTo(i); - break; - } - } - }, [activeKey, icons]); - - /** - * 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 ('tabBarIconLottieSource' in icon) { - return ( - - - - ); - } - return ( - - - - ); - })} - - - - ); -} - export function SplitNavigator({ children, initialRouteName, From fe716b5228755145fd71a8c10dac1a1d68f870ad Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 16 Nov 2021 11:46:16 +0300 Subject: [PATCH 09/20] Attempt to use lottie --- Example/package.json | 2 + casts/splitNavigator/package.json | 8 ++ .../src/SplitNavigator/LottieView.tsx | 1 + .../src/SplitNavigator/LottieView.web.tsx | 30 ++++++ .../src/SplitNavigator/SplitBottomTabBar.tsx | 36 ++++--- .../src/SplitNavigator/SplitRouter.ts | 9 +- .../SplitNavigator/createSplitNavigator.tsx | 18 ++-- yarn.lock | 102 ++++++------------ 8 files changed, 111 insertions(+), 95 deletions(-) create mode 100644 casts/splitNavigator/src/SplitNavigator/LottieView.tsx create mode 100644 casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx diff --git a/Example/package.json b/Example/package.json index 8941adb81..ba1686b7e 100644 --- a/Example/package.json +++ b/Example/package.json @@ -65,6 +65,8 @@ "html2canvas": "1.0.0-rc.7", "libphonenumber-js": "1.7.14", "lodash": "4.17.21", + "lottie-ios": "3.2.3", + "lottie-react-native": "4.1.3", "mobile-detect": "1.4.3", "moment": "2.24.0", "qrcode": "1.4.4", diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index 6d0bc0a17..595831a35 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -34,6 +34,10 @@ "src/" ], "dependencies": { + "@tonlabs/uikit.themes": "^2.3.0", + "@tonlabs/uikit.controls": "^2.3.0", + "@tonlabs/uikit.media": "^2.3.0", + "lottie-web": "5.8.1", "react-native-safe-area-context": "^3.1.3", "nanoid": "^3.1.23" }, @@ -44,6 +48,8 @@ "@react-navigation/stack": "^5.6.2", "@types/react": "17.0.21", "@types/react-native": "0.65.0", + "lottie-ios": "3.2.3", + "lottie-react-native": "4.1.3", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.65.1", @@ -59,6 +65,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", diff --git a/casts/splitNavigator/src/SplitNavigator/LottieView.tsx b/casts/splitNavigator/src/SplitNavigator/LottieView.tsx new file mode 100644 index 000000000..d529b15be --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/LottieView.tsx @@ -0,0 +1 @@ +export { default as LottieView } from 'lottie-react-native'; diff --git a/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx b/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx new file mode 100644 index 000000000..830fa44a9 --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import lottie, { AnimationItem, AnimationConfigWithData } from 'lottie-web'; + +class LottieWebView extends React.Component { + animationContainer = React.createRef(); + + animationInstance = React.createRef(); + + componentDidMount() { + if (this.animationContainer.current == null) { + return; + } + + this.animationInstance.current?.destroy(); + + const config: AnimationConfigWithData = { + container: this.animationContainer.current, + }; + + this.animationInstance.current = lottie.loadAnimation(config); + } + + setNativeProps({ style: { progress } }: { style: { progress: number } }) { + this.animationInstance.current?.goToAndStop(progress); + } + + render() { + return
; + } +} diff --git a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx index 2861dd725..eb75a62ad 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx +++ b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx @@ -1,9 +1,18 @@ import * as React from 'react'; -import { View, Animated, ImageSourcePropType, ImageRequireSource } from 'react-native'; +import { + View, + Animated, + ImageSourcePropType, + ImageRequireSource, + StyleProp, + ViewStyle, +} from 'react-native'; import { GestureEvent, NativeViewGestureHandlerPayload, + NativeViewGestureHandlerProps, RawButton as GHRawButton, + RawButtonProps, } from 'react-native-gesture-handler'; import ReAnimated, { runOnJS, @@ -27,17 +36,6 @@ export type SplitScreenTabBarIconOptions = tabBarIconLottieSource: ImageRequireSource; }; -/** - * TODO - */ -function LottieView(_props: { - source: ImageSourcePropType; - progress: Animated.Value; - style: StyleProp; -}) { - return null; -} - type LottieIconViewProps = { activeState: boolean; source: ImageRequireSource; @@ -87,6 +85,16 @@ function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconV ); } +export const RawButton: React.FunctionComponent< + ReAnimated.AnimateProps< + RawButtonProps & + NativeViewGestureHandlerProps & { + testID?: string; + style?: StyleProp; + } + > +> = Animated.createAnimatedComponent(GHRawButton); + function SplitBottomTabBarItem({ children, keyProp, @@ -107,7 +115,7 @@ function SplitBottomTabBarItem({ }, ); return ( - {children} - + ); } type SplitBottomTabBarDotRef = { moveTo(index: number): void }; diff --git a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts index 65b8b03df..31222ab1e 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -714,7 +714,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return state; } - console.log('getRehydratedState', state); return (isSplitted ? unfoldedRouter : foldedRouter).getRehydratedState(state, options); }, @@ -729,7 +728,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { }, getStateForAction(state: SplitNavigationState, action, options) { - console.log('getStateForAction', action); if (action.type === 'SET_SPLITTED') { ({ isSplitted } = action.payload); @@ -762,11 +760,8 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { ); }, - shouldActionChangeFocus(/* action */) { - console.log('shouldActionChangeFocus'); - // TODO - // return tabRouter.shouldActionChangeFocus(action); - return false; + shouldActionChangeFocus(action) { + return action.type === 'NAVIGATE'; }, }; diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 02db88472..945541219 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -147,6 +147,16 @@ export function SplitNavigator({ // Access it from the state to re-render a container // only when router has processed SET_SPLITTED action + const onTabPress = React.useCallback( + (key: string) => { + if (state.routes[state.index].key === key) { + return; + } + navigation.navigate({ key }); + }, + [navigation, state], + ); + if (state.isSplitted) { const mainRoute = state.routes.find( ({ name }: { name: string }) => name === MAIN_SCREEN_NAME, @@ -192,9 +202,7 @@ export function SplitNavigator({ { - navigation.navigate({ key }); - }} + onPress={onTabPress} /> @@ -403,9 +411,7 @@ export function SplitNavigator({ { - navigation.navigate({ key }); - }} + onPress={onTabPress} /> diff --git a/yarn.lock b/yarn.lock index dfa8ae435..1d0332657 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4908,11 +4908,6 @@ core-js-pure@^3.16.0: resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.17.3.tgz#98ea3587188ab7ef4695db6518eeb71aec42604a" integrity sha512-YusrqwiOTTn8058JDa0cv9unbXdIiIgcgI9gXso0ey4WgkFLd3lYlV9rp9n7nDCsYxXsMDTjA4m1h3T348mdlQ== -core-js@^1.0.0: - version "1.2.7" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" - integrity sha1-ZSKUwUZR2yj6k70tX/KYOk8IxjY= - core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -5261,6 +5256,11 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" +dedent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.6.0.tgz#0e6da8f0ce52838ef5cec5c8f9396b0c1b64a3cb" + integrity sha1-Dm2o8M5Sg471zsXI+TlrDBtko8s= + dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -6653,19 +6653,6 @@ fbjs-css-vars@^1.0.0: resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== -fbjs@^0.8.16: - version "0.8.17" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" - integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= - dependencies: - core-js "^1.0.0" - isomorphic-fetch "^2.1.1" - loose-envify "^1.0.0" - object-assign "^4.1.0" - promise "^7.1.1" - setimmediate "^1.0.5" - ua-parser-js "^0.7.18" - fbjs@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.0.tgz#0907067fb3f57a78f45d95f1eacffcacd623c165" @@ -7036,11 +7023,6 @@ functional-red-black-tree@^1.0.1: resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= -fuse.js@2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-2.6.2.tgz#d5d994fda96f543b5a51df38b72cec9cc60d9dea" - integrity sha1-1dmU/alvVDtaUd84tyzsnMYNneo= - fuse.js@3.4.6: version "3.4.6" resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.4.6.tgz#545c3411fed88bf2e27c457cab6e73e7af697a45" @@ -8021,7 +8003,7 @@ interpret@1.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== -invariant@^2.2.4: +invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -8381,7 +8363,7 @@ is-ssh@^1.3.0: dependencies: protocols "^1.1.0" -is-stream@^1.0.1, is-stream@^1.1.0: +is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= @@ -8497,14 +8479,6 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= -isomorphic-fetch@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz#611ae1acf14f5e81f729507472819fe9733558a9" - integrity sha1-YRrhrPFPXoH3KVB0coGf6XM1WKk= - dependencies: - node-fetch "^1.0.1" - whatwg-fetch ">=0.10.0" - isstream@~0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" @@ -9500,11 +9474,6 @@ lodash.throttle@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ= -lodash.toarray@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz#24c4bfcd6b2fba38bfd0594db1179d8e9b656561" - integrity sha1-JMS/zWsvuji/0FlNsRedjptlZWE= - lodash.truncate@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" @@ -9561,6 +9530,25 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1, loose-envify@^1.4 dependencies: js-tokens "^3.0.0 || ^4.0.0" +lottie-ios@3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/lottie-ios/-/lottie-ios-3.2.3.tgz#d5a029ccce611603d178ea7ba725c1446f2310b4" + integrity sha512-mubYMN6+1HXa8z3EJKBvNBkl4UoVM4McjESeB2PgvRMSngmJtC5yUMRdhbbrIAn5Liu3hFGao/14s5hQIgtkRQ== + +lottie-react-native@4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/lottie-react-native/-/lottie-react-native-4.1.3.tgz#813579b09f5346d6ceae21fd954f44f8392f8943" + integrity sha512-RgQrn1VYRXMV3YTZE9DgZy/UqNsMmZvzXBU4eEUWDOTY9cemOoWmCg2BHrL7nNtDJqtsu1Mi/6e8hp0yN2mcBA== + dependencies: + invariant "^2.2.2" + prop-types "^15.5.10" + react-native-safe-modules "^1.0.3" + +lottie-web@5.8.1: + version "5.8.1" + resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.8.1.tgz#807e0af0ad22b59bf867d964eb684cb3368da0ef" + integrity sha512-9gIizWADlaHC2GCt+D+yNpk5l2clZQFqnVWWIVdY0LnxC/uLa39dYltAe3fcmC/hrZ2IUQ8dLlY0O934Npjs7Q== + loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -10460,13 +10448,6 @@ node-dir@^0.1.17: dependencies: minimatch "^3.0.2" -node-emoji@1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.8.1.tgz#6eec6bfb07421e2148c75c6bba72421f8530a826" - integrity sha512-+ktMAh1Jwas+TnGodfCfjUbJKoANqPaJFN0z0iqh41eqD8dvguNzcitVSBSVK1pidz0AqGbLKcoVuVLRVZ/aVg== - dependencies: - lodash.toarray "^4.4.0" - node-eval@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/node-eval/-/node-eval-2.0.0.tgz#ae1d1299deb4c0e41352f9528c1af6401661d37f" @@ -10488,14 +10469,6 @@ node-fetch@2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== -node-fetch@^1.0.1: - version "1.7.3" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef" - integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ== - dependencies: - encoding "^0.1.11" - is-stream "^1.0.1" - node-fetch@^2.2.0, node-fetch@^2.5.0, node-fetch@^2.6.0, node-fetch@^2.6.1: version "2.6.2" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.2.tgz#986996818b73785e47b1965cc34eb093a1d464d0" @@ -11696,15 +11669,6 @@ prop-types@*, prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.6.2, prop- object-assign "^4.1.1" react-is "^16.8.1" -prop-types@15.6.0: - version "15.6.0" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.0.tgz#ceaf083022fc46b4a35f69e13ef75aed0d639856" - integrity sha1-zq8IMCL8RrSjX2nhPvda7Q1jmFY= - dependencies: - fbjs "^0.8.16" - loose-envify "^1.3.1" - object-assign "^4.1.1" - proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -12118,6 +12082,13 @@ react-native-safe-area-context@^3.1.3: resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-3.3.2.tgz#9549a2ce580f2374edb05e49d661258d1b8bcaed" integrity sha512-yOwiiPJ1rk+/nfK13eafbpW6sKW0jOnsRem2C1LPJjM3tfTof6hlvV5eWHATye3XOpu2cJ7N+HdkUvUDGwFD2Q== +react-native-safe-modules@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/react-native-safe-modules/-/react-native-safe-modules-1.0.3.tgz#f5f29bb9d09d17581193843d4173ad3054f74890" + integrity sha512-DUxti4Z+AgJ/ZsO5U7p3uSCUBko8JT8GvFlCeOXk9bMd+4qjpoDvMYpfbixXKgL88M+HwmU/KI1YFN6gsQZyBA== + dependencies: + dedent "^0.6.0" + react-native-screens@3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-3.6.0.tgz#054af728e50c06bff6b3b4fa7b4b656b70f247cd" @@ -14709,7 +14680,7 @@ whatwg-encoding@^1.0.5: dependencies: iconv-lite "0.4.24" -whatwg-fetch@>=0.10.0, whatwg-fetch@^3.0.0: +whatwg-fetch@^3.0.0: version "3.6.2" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.2.tgz#dced24f37f2624ed0281725d51d0e2e3fe677f8c" integrity sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA== @@ -14827,11 +14798,6 @@ worker-farm@^1.7.0: dependencies: errno "~0.1.7" -world-countries@1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/world-countries/-/world-countries-1.8.0.tgz#17f48e7e8470ac5a2136ad6938dfc656fc2bcb95" - integrity sha1-F/SOfoRwrFohNq1pON/GVvwry5U= - wrap-ansi@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" From 71385ccf080cbc9a0261a7eef3a196722f091275 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 16 Nov 2021 16:31:41 +0300 Subject: [PATCH 10/20] Remove lottie --- Example/package.json | 2 - Example/src/App.tsx | 9 +- casts/splitNavigator/package.json | 3 - .../src/SplitNavigator/LottieView.tsx | 1 - .../src/SplitNavigator/LottieView.web.tsx | 30 ------- .../src/SplitNavigator/MainAnimatedIcon.tsx | 84 +++++++++++++++++++ .../src/SplitNavigator/SplitBottomTabBar.tsx | 66 +++++++-------- .../SplitNavigator/createSplitNavigator.tsx | 17 +++- yarn.lock | 33 +------- 9 files changed, 129 insertions(+), 116 deletions(-) delete mode 100644 casts/splitNavigator/src/SplitNavigator/LottieView.tsx delete mode 100644 casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx create mode 100644 casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx diff --git a/Example/package.json b/Example/package.json index ba1686b7e..8941adb81 100644 --- a/Example/package.json +++ b/Example/package.json @@ -65,8 +65,6 @@ "html2canvas": "1.0.0-rc.7", "libphonenumber-js": "1.7.14", "lodash": "4.17.21", - "lottie-ios": "3.2.3", - "lottie-react-native": "4.1.3", "mobile-detect": "1.4.3", "moment": "2.24.0", "qrcode": "1.4.4", diff --git a/Example/src/App.tsx b/Example/src/App.tsx index ffc0b6353..b825d3e60 100644 --- a/Example/src/App.tsx +++ b/Example/src/App.tsx @@ -345,14 +345,7 @@ const App = () => { - + diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index 595831a35..5e422189e 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -37,7 +37,6 @@ "@tonlabs/uikit.themes": "^2.3.0", "@tonlabs/uikit.controls": "^2.3.0", "@tonlabs/uikit.media": "^2.3.0", - "lottie-web": "5.8.1", "react-native-safe-area-context": "^3.1.3", "nanoid": "^3.1.23" }, @@ -48,8 +47,6 @@ "@react-navigation/stack": "^5.6.2", "@types/react": "17.0.21", "@types/react-native": "0.65.0", - "lottie-ios": "3.2.3", - "lottie-react-native": "4.1.3", "react": "17.0.2", "react-dom": "17.0.2", "react-native": "0.65.1", diff --git a/casts/splitNavigator/src/SplitNavigator/LottieView.tsx b/casts/splitNavigator/src/SplitNavigator/LottieView.tsx deleted file mode 100644 index d529b15be..000000000 --- a/casts/splitNavigator/src/SplitNavigator/LottieView.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as LottieView } from 'lottie-react-native'; diff --git a/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx b/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx deleted file mode 100644 index 830fa44a9..000000000 --- a/casts/splitNavigator/src/SplitNavigator/LottieView.web.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import * as React from 'react'; -import lottie, { AnimationItem, AnimationConfigWithData } from 'lottie-web'; - -class LottieWebView extends React.Component { - animationContainer = React.createRef(); - - animationInstance = React.createRef(); - - componentDidMount() { - if (this.animationContainer.current == null) { - return; - } - - this.animationInstance.current?.destroy(); - - const config: AnimationConfigWithData = { - container: this.animationContainer.current, - }; - - this.animationInstance.current = lottie.loadAnimation(config); - } - - setNativeProps({ style: { progress } }: { style: { progress: number } }) { - this.animationInstance.current?.goToAndStop(progress); - } - - render() { - return
; - } -} diff --git a/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx b/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx new file mode 100644 index 000000000..45952bd27 --- /dev/null +++ b/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx @@ -0,0 +1,84 @@ +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 circle1 = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${bgColorParts}, 0)`, `rgba(${bgColorParts}, 1)`], + ), + borderColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 0)`], + ), + transform: [ + { + scale: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [0.5, 1], + ), + }, + ], + }; + }); + const circle2 = useAnimatedStyle(() => { + return { + backgroundColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${centerDotColorParts}, 0)`, `rgba(${centerDotColorParts}, 1)`], + ), + borderColor: interpolateColor( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 0)`], + ), + transform: [ + { + scale: interpolate( + progress.value, + [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], + [1, 0.4], + ), + }, + ], + }; + }); + return ( + + + + + ); +} diff --git a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx index eb75a62ad..88d273bac 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx +++ b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx @@ -1,12 +1,5 @@ import * as React from 'react'; -import { - View, - Animated, - ImageSourcePropType, - ImageRequireSource, - StyleProp, - ViewStyle, -} from 'react-native'; +import { View, ImageSourcePropType, StyleProp, ViewStyle } from 'react-native'; import { GestureEvent, NativeViewGestureHandlerPayload, @@ -27,42 +20,43 @@ import { UIImage } from '@tonlabs/uikit.media'; import { useTheme, ColorVariants, UIBackgroundView } from '@tonlabs/uikit.themes'; import { hapticSelection } from '@tonlabs/uikit.controls'; +type SplitScreenTabBarAnimatedIconComponent = React.ComponentType<{ + progress: ReAnimated.SharedValue; + style?: StyleProp; +}>; + export type SplitScreenTabBarIconOptions = | { tabBarActiveIcon: ImageSourcePropType; tabBarDisabledIcon: ImageSourcePropType; } | { - tabBarIconLottieSource: ImageRequireSource; + tabBarAnimatedIcon: SplitScreenTabBarAnimatedIconComponent; }; -type LottieIconViewProps = { +// @inline +const ANIMATED_ICON_INACTIVE = 0; +// @inline +const ANIMATED_ICON_ACTIVE = 1; + +type AnimatedIconViewProps = { activeState: boolean; - source: ImageRequireSource; + component: SplitScreenTabBarAnimatedIconComponent; }; -function LottieIconView({ activeState, source }: LottieIconViewProps) { - const progress = React.useRef(new Animated.Value(activeState ? 1 : 0)).current; - // React.useImperativeHandle(ref, () => ({ - // activate() { - // /** - // * TODO: maybe linear is better to keep it in sync with the dot? - // */ - // Animated.spring(progress, { - // toValue: 1, - // useNativeDriver: true, - // }); - // }, - // disable() { - // Animated.spring(progress, { - // toValue: 0, - // useNativeDriver: true, - // }); - // }, - // })); + +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 ( - ; } > -> = Animated.createAnimatedComponent(GHRawButton); +> = ReAnimated.createAnimatedComponent(GHRawButton); function SplitBottomTabBarItem({ children, @@ -258,11 +252,11 @@ export function SplitBottomTabBar({ > {Object.keys(icons).map(key => { const icon = icons[key]; - if ('tabBarIconLottieSource' in icon) { + if ('tabBarAnimatedIcon' in icon) { return ( - diff --git a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 945541219..7e20aee46 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -25,6 +25,7 @@ import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; import { SplitRouter, SplitActions, MAIN_SCREEN_NAME, SplitActionHelpers } from './SplitRouter'; import type { SplitNavigationState, SplitRouterOptions } from './SplitRouter'; import { SplitBottomTabBar, SplitScreenTabBarIconOptions } from './SplitBottomTabBar'; +import { MainAnimatedIcon } from './MainAnimatedIcon'; export const NestedInSplitContext = React.createContext<{ isSplitted: boolean; @@ -181,9 +182,9 @@ export function SplitNavigator({ tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, }; } - if ('tabBarIconLottieSource' in descriptor.options) { + if ('tabBarAnimatedIcon' in descriptor.options) { acc[key] = { - tabBarIconLottieSource: descriptor.options.tabBarIconLottieSource, + tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, }; } @@ -251,6 +252,7 @@ export function SplitNavigator({ ); } + const mainRoute = state.routes.find(({ name }: { name: string }) => name === MAIN_SCREEN_NAME); const tabBarIcons = Object.keys(descriptors).reduce< Record >((acc, key) => { @@ -263,10 +265,17 @@ export function SplitNavigator({ tabBarActiveIcon: descriptor.options.tabBarActiveIcon, tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, }; + return acc; + } + if ('tabBarAnimatedIcon' in descriptor.options) { + acc[key] = { + tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, + }; + return acc; } - if ('tabBarIconLottieSource' in descriptor.options) { + if (mainRoute?.key === key) { acc[key] = { - tabBarIconLottieSource: descriptor.options.tabBarIconLottieSource, + tabBarAnimatedIcon: MainAnimatedIcon, }; } diff --git a/yarn.lock b/yarn.lock index 1d0332657..7087b9265 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5256,11 +5256,6 @@ decompress-response@^3.3.0: dependencies: mimic-response "^1.0.0" -dedent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.6.0.tgz#0e6da8f0ce52838ef5cec5c8f9396b0c1b64a3cb" - integrity sha1-Dm2o8M5Sg471zsXI+TlrDBtko8s= - dedent@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" @@ -8003,7 +7998,7 @@ interpret@1.2.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== -invariant@^2.2.2, invariant@^2.2.4: +invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -9530,25 +9525,6 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.1, loose-envify@^1.4 dependencies: js-tokens "^3.0.0 || ^4.0.0" -lottie-ios@3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/lottie-ios/-/lottie-ios-3.2.3.tgz#d5a029ccce611603d178ea7ba725c1446f2310b4" - integrity sha512-mubYMN6+1HXa8z3EJKBvNBkl4UoVM4McjESeB2PgvRMSngmJtC5yUMRdhbbrIAn5Liu3hFGao/14s5hQIgtkRQ== - -lottie-react-native@4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/lottie-react-native/-/lottie-react-native-4.1.3.tgz#813579b09f5346d6ceae21fd954f44f8392f8943" - integrity sha512-RgQrn1VYRXMV3YTZE9DgZy/UqNsMmZvzXBU4eEUWDOTY9cemOoWmCg2BHrL7nNtDJqtsu1Mi/6e8hp0yN2mcBA== - dependencies: - invariant "^2.2.2" - prop-types "^15.5.10" - react-native-safe-modules "^1.0.3" - -lottie-web@5.8.1: - version "5.8.1" - resolved "https://registry.yarnpkg.com/lottie-web/-/lottie-web-5.8.1.tgz#807e0af0ad22b59bf867d964eb684cb3368da0ef" - integrity sha512-9gIizWADlaHC2GCt+D+yNpk5l2clZQFqnVWWIVdY0LnxC/uLa39dYltAe3fcmC/hrZ2IUQ8dLlY0O934Npjs7Q== - loud-rejection@^1.0.0: version "1.6.0" resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f" @@ -12082,13 +12058,6 @@ react-native-safe-area-context@^3.1.3: resolved "https://registry.yarnpkg.com/react-native-safe-area-context/-/react-native-safe-area-context-3.3.2.tgz#9549a2ce580f2374edb05e49d661258d1b8bcaed" integrity sha512-yOwiiPJ1rk+/nfK13eafbpW6sKW0jOnsRem2C1LPJjM3tfTof6hlvV5eWHATye3XOpu2cJ7N+HdkUvUDGwFD2Q== -react-native-safe-modules@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/react-native-safe-modules/-/react-native-safe-modules-1.0.3.tgz#f5f29bb9d09d17581193843d4173ad3054f74890" - integrity sha512-DUxti4Z+AgJ/ZsO5U7p3uSCUBko8JT8GvFlCeOXk9bMd+4qjpoDvMYpfbixXKgL88M+HwmU/KI1YFN6gsQZyBA== - dependencies: - dedent "^0.6.0" - react-native-screens@3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-3.6.0.tgz#054af728e50c06bff6b3b4fa7b4b656b70f247cd" From d1643152be20072344e5a7d648d576ce08b287d6 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 16 Nov 2021 17:58:20 +0300 Subject: [PATCH 11/20] Changes --- casts/splitNavigator/package.json | 1 + .../SplitNavigator/ResourceSavingScene.tsx | 2 + .../SplitNavigator/createSplitNavigator.tsx | 244 ++++++++---------- yarn.lock | 5 + 4 files changed, 112 insertions(+), 140 deletions(-) diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index 5e422189e..e756b991e 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -37,6 +37,7 @@ "@tonlabs/uikit.themes": "^2.3.0", "@tonlabs/uikit.controls": "^2.3.0", "@tonlabs/uikit.media": "^2.3.0", + "react-freeze": "^1.0.0", "react-native-safe-area-context": "^3.1.3", "nanoid": "^3.1.23" }, 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/createSplitNavigator.tsx b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx index 7e20aee46..7484c739a 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -12,13 +12,13 @@ import { NavigationHelpersContext, useNavigationBuilder, createNavigatorFactory, - useTheme as useNavTheme, } 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 { NativeStackView } from 'react-native-screens/native-stack'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Freeze } from 'react-freeze'; import { ResourceSavingScene } from './ResourceSavingScene'; import { SafeAreaProviderCompat } from './SafeAreaProviderCompat'; @@ -33,20 +33,6 @@ export const NestedInSplitContext = React.createContext<{ const getIsSplitted = ({ width }: { width: number }, mainWidth: number) => width > mainWidth; -function SceneContent({ isFocused, children }: { isFocused: boolean; children: React.ReactNode }) { - const { colors } = useNavTheme(); - - return ( - - {children} - - ); -} - const SplitTabBarHeightContext = React.createContext(0); export function useSplitTabBarHeight() { return React.useContext(SplitTabBarHeightContext); @@ -65,6 +51,18 @@ type SplitNavigatorProps = { } & SplitRouterOptions; }; type SplitScreenOptions = SplitScreenTabBarIconOptions | Record; +type TabScreenProps = { + isFocused: boolean; + children: React.ReactNode; + style?: StyleProp; +}; +function TabScreen({ isFocused, style, children }: TabScreenProps) { + return ( + + {children} + + ); +} export function SplitNavigator({ children, @@ -158,13 +156,12 @@ export function SplitNavigator({ [navigation, state], ); + 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!`); + } + 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!`); - } const tabBarIcons = Object.keys(descriptors).reduce< Record >((acc, key) => { @@ -215,14 +212,14 @@ export function SplitNavigator({ style={styles.pages} > {state.routes.map((route, index) => { - const descriptor = descriptors[route.key]; - const isFocused = state.tabIndex === 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 @@ -237,9 +234,7 @@ export function SplitNavigator({ style={StyleSheet.absoluteFill} isVisible={isFocused} > - - {descriptor.render()} - + {descriptor.render()} ); })} @@ -252,7 +247,6 @@ export function SplitNavigator({ ); } - const mainRoute = state.routes.find(({ name }: { name: string }) => name === MAIN_SCREEN_NAME); const tabBarIcons = Object.keys(descriptors).reduce< Record >((acc, key) => { @@ -273,7 +267,7 @@ export function SplitNavigator({ }; return acc; } - if (mainRoute?.key === key) { + if (mainRoute.key === key) { acc[key] = { tabBarAnimatedIcon: MainAnimatedIcon, }; @@ -291,138 +285,108 @@ export function SplitNavigator({ return ( - - - {state.routes.map((route, index) => { - 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; - } + + + + {state.routes.map((route, index) => { + const descriptor = descriptors[route.key]; + const isFocused = state.tabIndex === index; - if (route.name === MAIN_SCREEN_NAME) { - if (state.nestedStack == 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; } - if (doesSupportNative) { + + if (route.key === mainRoute.key) { + if (state.nestedStack == null) { + return null; + } + if (doesSupportNative) { + return ( + + { + return state.routes[routeIndex]; + }, + ), + }} + navigation={navigation} + // @ts-ignore + descriptors={stackDescriptors} + /> + + ); + } + return ( - - - - { - return state.routes[ - routeIndex - ]; - }, - ), - }} - navigation={navigation} - // @ts-ignore - descriptors={stackDescriptors} - /> - - - + {/* @ts-ignore */} + { + return state.routes[routeIndex]; + }), + }} + navigation={navigation} + // @ts-ignore + descriptors={stackDescriptors} + /> ); } + if (stackDescriptors[route.key] != null) { + return null; + } + return ( - - - - {/* @ts-ignore */} - { - return state.routes[routeIndex]; - }, - ), - }} - navigation={navigation} - // @ts-ignore - descriptors={stackDescriptors} - /> - - - + {descriptor.render()} ); - } - - if (stackDescriptors[route.key] != null) { - return null; - } - - return ( - - - - {descriptor.render()} - - - - ); - })} - - - + })} + + + + ); diff --git a/yarn.lock b/yarn.lock index 7087b9265..25690b10a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11874,6 +11874,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" From 5a95debafec96bfe177bacb713a2beb75960861d Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Wed, 17 Nov 2021 19:15:57 +0300 Subject: [PATCH 12/20] New split navigator with tabs --- Example/package.json | 1 + Example/src/App.tsx | 11 +- casts/splitNavigator/package.json | 2 + .../src/SplitNavigator/MainAnimatedIcon.tsx | 53 +- .../src/SplitNavigator/ShadowView.tsx | 2 + .../src/SplitNavigator/ShadowView.web.tsx | 3 + .../src/SplitNavigator/SplitBottomTabBar.tsx | 186 +++-- .../src/SplitNavigator/SplitRouter.ts | 41 +- .../src/SplitNavigator/TabScreen.tsx | 51 ++ .../SplitNavigator/createSplitNavigator.tsx | 701 +++++++++++------- casts/splitNavigator/types/index.d.ts | 1 + .../StackNavigator/useStackTopInsetStyle.tsx | 2 +- yarn.lock | 5 + 13 files changed, 649 insertions(+), 410 deletions(-) create mode 100644 casts/splitNavigator/src/SplitNavigator/ShadowView.tsx create mode 100644 casts/splitNavigator/src/SplitNavigator/ShadowView.web.tsx create mode 100644 casts/splitNavigator/src/SplitNavigator/TabScreen.tsx create mode 100644 casts/splitNavigator/types/index.d.ts diff --git a/Example/package.json b/Example/package.json index 8941adb81..7a5d3a544 100644 --- a/Example/package.json +++ b/Example/package.json @@ -94,6 +94,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 b825d3e60..89190454f 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'; @@ -37,10 +37,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 '../../casts/splitNavigator/src'; +import { createSplitNavigator, useSplitTabBarHeight } from '@tonlabs/uicast.split-navigator'; import { ButtonsScreen } from './screens/Buttons'; import { Checkbox } from './screens/Checkbox'; @@ -323,12 +322,6 @@ const App = () => { }, ], }, - ...Platform.select({ - android: { - stackAnimation: 'slide_from_right', - }, - default: null, - }), }} mainWidth={900} > diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index e756b991e..459ba7be3 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -56,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" }, @@ -73,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 index 45952bd27..1069cb499 100644 --- a/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx +++ b/casts/splitNavigator/src/SplitNavigator/MainAnimatedIcon.tsx @@ -2,11 +2,7 @@ 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'; +import { useColorParts, ColorVariants, useTheme } from '@tonlabs/uikit.themes'; // @inline const ANIMATED_ICON_INACTIVE = 0; @@ -22,9 +18,10 @@ type MainAnimatedIconProps = { export function MainAnimatedIcon({ progress, style }: MainAnimatedIconProps) { const { colorParts: bgColorParts } = useColorParts(ColorVariants.BackgroundAccent); - const { colorParts: borderColorParts } = useColorParts( - ColorVariants.BackgroundTertiaryInverted, - ); + // const { colorParts: borderColorParts } = useColorParts( + // ColorVariants.BackgroundTertiaryInverted, + // ); + const theme = useTheme(); const circle1 = useAnimatedStyle(() => { return { backgroundColor: interpolateColor( @@ -32,16 +29,23 @@ export function MainAnimatedIcon({ progress, style }: MainAnimatedIconProps) { [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], [`rgba(${bgColorParts}, 0)`, `rgba(${bgColorParts}, 1)`], ), - borderColor: interpolateColor( + borderWidth: interpolate( progress.value, [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], - [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 0)`], + [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], ), }, @@ -55,17 +59,24 @@ export function MainAnimatedIcon({ progress, style }: MainAnimatedIconProps) { [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], [`rgba(${centerDotColorParts}, 0)`, `rgba(${centerDotColorParts}, 1)`], ), - borderColor: interpolateColor( + borderWidth: interpolate( progress.value, [ANIMATED_ICON_INACTIVE, ANIMATED_ICON_ACTIVE], - [`rgba(${borderColorParts}, 1)`, `rgba(${borderColorParts}, 0)`], + [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], ), }, ], @@ -74,10 +85,24 @@ export function MainAnimatedIcon({ progress, style }: MainAnimatedIconProps) { return ( ); 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 index 88d273bac..890b05f0f 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx +++ b/casts/splitNavigator/src/SplitNavigator/SplitBottomTabBar.tsx @@ -1,5 +1,6 @@ +/* eslint-disable react/no-unused-prop-types */ import * as React from 'react'; -import { View, ImageSourcePropType, StyleProp, ViewStyle } from 'react-native'; +import { View, ImageSourcePropType, StyleProp, ViewStyle, StyleSheet } from 'react-native'; import { GestureEvent, NativeViewGestureHandlerPayload, @@ -17,8 +18,9 @@ import ReAnimated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { UIImage } from '@tonlabs/uikit.media'; -import { useTheme, ColorVariants, UIBackgroundView } from '@tonlabs/uikit.themes'; +import { useTheme, ColorVariants } from '@tonlabs/uikit.themes'; import { hapticSelection } from '@tonlabs/uikit.controls'; +import { ShadowView } from './ShadowView'; type SplitScreenTabBarAnimatedIconComponent = React.ComponentType<{ progress: ReAnimated.SharedValue; @@ -38,6 +40,7 @@ export type SplitScreenTabBarIconOptions = const ANIMATED_ICON_INACTIVE = 0; // @inline const ANIMATED_ICON_ACTIVE = 1; +const TAB_BAR_ICON_SIZE = 22; type AnimatedIconViewProps = { activeState: boolean; @@ -55,13 +58,7 @@ function AnimatedIconView({ activeState, component }: AnimatedIconViewProps) { const Comp = component; - return ( - - ); + return ; } type ImageIconViewProps = { @@ -70,13 +67,7 @@ type ImageIconViewProps = { disabledSource: ImageSourcePropType; }; function ImageIconView({ activeState, activeSource, disabledSource }: ImageIconViewProps) { - return ( - - ); + return ; } export const RawButton: React.FunctionComponent< @@ -109,33 +100,32 @@ function SplitBottomTabBarItem({ }, ); 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) * 64 - 32 - 4 / 2); + 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) * 64 - 32 - 4 / 2, { - overshootClamping: true, - }); + position.value = withSpring( + (index + 1) * TAB_BAR_HEIGHT - TAB_BAR_HEIGHT / 2 - TAB_BAR_DOT_SIZE / 2, + { + overshootClamping: true, + }, + ); }, })); @@ -151,12 +141,8 @@ const SplitBottomTabBarDot = React.memo( return ( (null); const prevActiveKey = React.useRef(activeKey); const initialDotIndex = React.useRef(-1); - if (initialDotIndex.current === -1) { - const iconsArr = Object.keys(icons); - for (let i = 0; i < iconsArr.length; i += 1) { - if (iconsArr[i] === activeKey) { - initialDotIndex.current = i; - break; - } - } + + 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 iconsArr = Object.keys(icons); - for (let i = 0; i < iconsArr.length; i += 1) { - if (iconsArr[i] === activeKey) { - dotRef.current?.moveTo(i); - break; - } + + const index = iconsMap[activeKey]; + console.log(index); + if (index != null) { + dotRef.current?.moveTo(index); } - }, [activeKey, icons]); + }, [activeKey, icons, iconsMap]); + const hasIconForActiveKey = React.useMemo(() => { - const iconsArr = Object.keys(icons); - for (let i = 0; i < iconsArr.length; i += 1) { - if (iconsArr[i] === activeKey) { - return true; - } - } - return false; - }, [icons, activeKey]); + return iconsMap[activeKey] != null; + }, [activeKey, iconsMap]); /** * Do not show tab bar when there're only @@ -225,30 +212,22 @@ export function SplitBottomTabBar({ return ( - {Object.keys(icons).map(key => { const icon = icons[key]; @@ -275,7 +254,52 @@ export function SplitBottomTabBar({ {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 31222ab1e..24e8da002 100644 --- a/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts +++ b/casts/splitNavigator/src/SplitNavigator/SplitRouter.ts @@ -58,10 +58,10 @@ type SplitRouterCustomOptions = { export type SplitRouterOptions = DefaultRouterOptions> & SplitRouterCustomOptions; -type NavigationRoute = Route< - Extract, - ParamList[RouteName] -> & { +export type NavigationRoute< + ParamList extends ParamListBase, + RouteName extends keyof ParamList, +> = Route, ParamList[RouteName]> & { state?: NavigationState | PartialState; }; @@ -120,8 +120,6 @@ export type SplitNavigationState[]; /** * Whether the navigation state has been rehydrated. */ @@ -284,12 +282,13 @@ class SplitUnfoldedRouter { } getRehydratedState( - partialState: PartialState>, + 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, @@ -331,10 +330,7 @@ class SplitUnfoldedRouter { }; } - getStateForRouteFocus( - state: SplitNavigationState, - key: string, - ): SplitNavigationState { + getStateForRouteFocus(state: SplitNavigationState, key: string): SplitNavigationState { const index = state.routes.findIndex(r => r.key === key); if (index === -1 || index === state.index) { @@ -472,7 +468,7 @@ class SplitFoldedRouter { } getRehydratedState( - partialState: PartialState>, + partialState: SplitNavigationState | PartialState, { routeNames, routeParamList }: RouterConfigOptions, ): SplitNavigationState { const { initialRouteName, tabRouteNames, stackRouteNames, isSplitted } = this.options; @@ -498,8 +494,9 @@ class SplitFoldedRouter { history.push(nestedStackRouteNameIndex); } else if (initialRouteName) { // nothing was found in known routes - // TODO: Maybe it's a good place to redirect to 404 // 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); @@ -527,6 +524,7 @@ class SplitFoldedRouter { history, nestedStack, routes: routeNames.map(name => { + // @ts-ignore const route = partialState.routes?.find(r => r.name === name); return { ...route, @@ -544,10 +542,7 @@ class SplitFoldedRouter { }; } - getStateForRouteFocus( - state: SplitNavigationState, - key: string, - ): SplitNavigationState { + getStateForRouteFocus(state: SplitNavigationState, key: string): SplitNavigationState { const index = state.routes.findIndex(r => r.key === key); if (index === -1 || index === state.index) { @@ -612,12 +607,11 @@ class SplitFoldedRouter { return { ...state, nestedStack, - history: state.history.slice(0, history.length - 2), + 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 - // TODO: copy/paste from unfolded if (state.history.length < 2) { return null; } @@ -707,11 +701,14 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { * }] * } */ - getRehydratedState(state, options): SplitNavigationState { + getRehydratedState( + state: SplitNavigationState | PartialState, + options, + ): SplitNavigationState { const isStale = state.stale; if (isStale === false) { - return state; + return state as SplitNavigationState; } return (isSplitted ? unfoldedRouter : foldedRouter).getRehydratedState(state, options); @@ -722,7 +719,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return state; }, - // TODO getStateForRouteFocus(state, key) { return (isSplitted ? unfoldedRouter : foldedRouter).getStateForRouteFocus(state, key); }, @@ -736,7 +732,6 @@ export function SplitRouter(routerOptions: SplitRouterOptions) { return state; } - // TODO: what it's for? if (action.payload.initialRouteName) { foldedRouter.options.initialRouteName = action.payload.initialRouteName; unfoldedRouter.options.initialRouteName = action.payload.initialRouteName; 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 7484c739a..553d647d2 100644 --- a/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx +++ b/casts/splitNavigator/src/SplitNavigator/createSplitNavigator.tsx @@ -7,7 +7,7 @@ import { Platform, StyleProp, } from 'react-native'; -import type { EventMapBase, NavigationState } from '@react-navigation/core'; +import type { Descriptor, EventMapBase, NavigationState } from '@react-navigation/core'; import { NavigationHelpersContext, useNavigationBuilder, @@ -15,21 +15,28 @@ import { } 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 { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { Freeze } from 'react-freeze'; 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 } from './SplitBottomTabBar'; +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; @@ -38,29 +45,330 @@ export function useSplitTabBarHeight() { return React.useContext(SplitTabBarHeightContext); } +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; -type TabScreenProps = { - isFocused: boolean; - children: React.ReactNode; - style?: StyleProp; -}; -function TabScreen({ isFocused, style, children }: TabScreenProps) { +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 ( - - {children} - + + + + {/* @ts-ignore */} + + + + ); } @@ -72,39 +380,57 @@ export function SplitNavigator({ }: 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, - }; - // TODO: optimise me! - 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 || - 'tabBarIconLottieSource' in child.props.options)) - ) { - acc.tabRouteNames.push(child.props.name); - } else { - acc.stackRouteNames.push(child.props.name); + 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: [], - }, - ); + 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, @@ -113,18 +439,28 @@ export function SplitNavigator({ 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, }); - console.log(state); React.useEffect(() => { navigation.dispatch( @@ -140,9 +476,6 @@ export function SplitNavigator({ } }, [state, loaded]); - const insets = useSafeAreaInsets(); - const tabBarHeight = Math.max(insets?.bottom, 32 /* TODO */) + 64; /* TODO */ - // Access it from the state to re-render a container // only when router has processed SET_SPLITTED action @@ -153,7 +486,7 @@ export function SplitNavigator({ } navigation.navigate({ key }); }, - [navigation, state], + [navigation, state.routes, state.index], ); const mainRoute = state.routes.find(({ name }: { name: string }) => name === MAIN_SCREEN_NAME); @@ -162,233 +495,32 @@ export function SplitNavigator({ } if (state.isSplitted) { - const tabBarIcons = Object.keys(descriptors).reduce< - Record - >((acc, key) => { - if (key === mainRoute.key) { - return acc; - } - - const descriptor = descriptors[key]; - if (descriptor.options == null) { - return acc; - } - if ('tabBarActiveIcon' in descriptor.options) { - acc[key] = { - tabBarActiveIcon: descriptor.options.tabBarActiveIcon, - tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, - }; - } - if ('tabBarAnimatedIcon' in descriptor.options) { - acc[key] = { - tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, - }; - } - - return acc; - }, {}); - 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()} - - ); - })} - - - - - - + ); } - const tabBarIcons = Object.keys(descriptors).reduce< - Record - >((acc, key) => { - const descriptor = descriptors[key]; - if (descriptor.options == null) { - return acc; - } - if ('tabBarActiveIcon' in descriptor.options) { - acc[key] = { - tabBarActiveIcon: descriptor.options.tabBarActiveIcon, - tabBarDisabledIcon: descriptor.options.tabBarDisabledIcon, - }; - return acc; - } - if ('tabBarAnimatedIcon' in descriptor.options) { - acc[key] = { - tabBarAnimatedIcon: descriptor.options.tabBarAnimatedIcon, - }; - return acc; - } - if (mainRoute.key === key) { - acc[key] = { - tabBarAnimatedIcon: MainAnimatedIcon, - }; - } - - return acc; - }, {}); - const stackDescriptors = state.routes.reduce((acc, route, index) => { - const descriptor = descriptors[route.key]; - if (state.nestedStack && state.nestedStack.includes(index)) { - acc[route.key] = descriptor; - } - return acc; - }, {}); return ( - - - - - - {state.routes.map((route, index) => { - 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; - } - - if (route.key === mainRoute.key) { - if (state.nestedStack == null) { - return null; - } - if (doesSupportNative) { - return ( - - { - return state.routes[routeIndex]; - }, - ), - }} - navigation={navigation} - // @ts-ignore - descriptors={stackDescriptors} - /> - - ); - } - - return ( - - {/* @ts-ignore */} - { - return state.routes[routeIndex]; - }), - }} - navigation={navigation} - // @ts-ignore - descriptors={stackDescriptors} - /> - - ); - } - - if (stackDescriptors[route.key] != null) { - return null; - } - - return ( - - {descriptor.render()} - - ); - })} - - - - - - + ); } @@ -423,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/yarn.lock b/yarn.lock index 25690b10a..17d88e28e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12082,6 +12082,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" From c1c8d32ed615bace0270a78fe546acf88909a8f4 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Thu, 18 Nov 2021 18:50:40 +0300 Subject: [PATCH 13/20] OverScrollView for android --- .../libs/overscroll-release-v1.1-20160904.jar | Bin 0 -> 47174 bytes .../java/tonlabs/uikit/MainApplication.java | 1 + .../tonlabs/uikit/ReactOverScrollPackage.java | 28 +++ .../tonlabs/uikit/ReactOverScrollView.java | 204 ++++++++++++++++++ .../uikit/ReactOverScrollViewManager.java | 28 +++ .../useOnScrollHandler/onScroll.native.tsx | 14 +- .../wrapScrollableComponent.android.tsx | 44 +--- 7 files changed, 284 insertions(+), 35 deletions(-) create mode 100644 Example/android/app/libs/overscroll-release-v1.1-20160904.jar create mode 100644 Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java create mode 100644 Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java create mode 100644 Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollViewManager.java 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 0000000000000000000000000000000000000000..4e1d2d3488ca42d0fe7acbf14a964bd26a7970dc GIT binary patch literal 47174 zcmb@tV~}TEx8<95Rc2PA(zb2ewr$(^r)}G|ZQHi(N@w1B-qU^ibVu|Xan8LvVtv@L zWA6`Z#2hpB_^l}?2?~Y`1O){JbmC7W4)mWcWFRmgX<;RPYH=A6T4{b6aS>rfB^qgw zuW=xtgCh_iIY~&U%AkB?u)pts{9SVY@0UdF{`W7j{C%o#W8`3KZba+oY-eZd;6&@f zO6zKFWNPe0E9+wHplIk|Yh|TiZ0K%iW$d70ZtO~9Xr=Gyn5Ckvh@^_-?|7H!TJ4L2aZa3r)$o@FRJaSV^SBz?_uzkoxdI=mJBU^dyH}^~v3nq^4LB5^*9l87+lwg0ZY2xvzCIXz} zH>E-!{8Kd)^YEvX)8|7phOmI)R7$8aBkT@7@D7q1_n6G!HpdomZ9Ozw=VI}Og%s==2dh^* z9}B_5O3T#Y_k2VS!AMqg8P^cH#~SzGwhpVs#7L$Qn;|4GRc5Wb*XsF~)*O+}OAJqI zj~$n&^=RYeC*lDc3}^N~&{Srv(J|&BsSeDQHDrEyGpm)H*Tts;%lv~=i9@S8~=dsP<-`)Gi_I_!uZq7McO@EOOYz$Y7}oHAPg`Y`kG)Uo< z#J6G(JsZZo>6Lh*b058E1tN;-=%!pjZ!TTMc1hEsr>F!}*{}zt3t1b%tpdm9=SHqa z%Yde=m~LqJvZLZvmLjQ9hwp`5Ht{!MH#LK$Ou(NfZb_?_xoOk;Nyh?nxqa{rk|?D} zk1WL!$aTizDt^#>SpW~ced}z+GuYarO`aw0)>g?An)mY&Ew%tsiofm|i-=7xkiWk? zAO=wnE5PYF`X(A54s?Ee9wVWUFx)B04n*8*A%tx$?ujZsQ3rQge7T|9x3`>JML(+; z&LG}b>W)`9^UL37AD4H~Z3O6d>9#mPHxc-r2M=l%7wl9OIM7*yrNW z$?n3VY=l=V;A%y{1O9!UpBEJFEG0exY*WtBNPXeIMvAUHrj}pKc2;V62B^HVP0t+VbRNgr_1SI{NhEWru`Ue2 zx)82rQQ{Xg>H?h4Vy8R}G$BAlX36k65QXAxFH*lOx&sJ|UMdM&(ZC;~cax%bX9{*t zu=_0C3VT_VeH9(-VhXnGLW@z)Z75DfB&4JECiK);k<}Gqt3=zlByth=B_OoKnaM@A zXq`*X#T&~5qLsa+F;^kKrSKUaxifLGF=vJw!mJoF#{ZZsguE0F9Ys8V-+9S$J_H?Z z-rMtA4A3pkhSZUV&`>Fc8KN22@6YcW5f?TQ!{h5)CnUu$|5WdquQwyvS6!o_px7{1 zwxIf!ES$y_a2^J5RMhU6{TgG3`Kl94Fkq6muiE2e|7{;A+bdoC4gQZ^n^`%#N`eCc z@%{MEcKv@EKhs~kHng>-wKjJ%*SGy=(c1p?Xvcqf^nbEzA!93JQ++36BKrR@>m)g8 z$pJnD?~Tf+s5vvGPLJDMf+}3mh?H_XA#lPj+l3bC|iXq*q=FEaaIuz+j^ zx`*xVthKDPnVM&Kppw-;a|Wj-EH_8z>d>0*Gt)(s45m%jTS(HD9^_4#p$gYoFS~G` zBFsGs8r^1Nv)7abvqtcCUeZ$~aVzaA5pfAej`(!G2-&P-OvlOJ|cn zXMyM+;TebY=5V~naqUB#JOAuF28B#F8ZZzL#NST&zZ&KLx$^@5)t&!ey;IT2-OBhs zbSP3!V;MiYyT9Pm^BMIb6<2JBB2P((JAu#mid-zvgbo|fFl z+biiqOshU_e}s=4YrBW3(6*&OL?;;@uCy@0?LEW2G?|b2#MRwHVszR}5d2U>|5$XvZc8&t zatke!p_VK7(#wEpieO5&(I2DLmIHJ(BqA^rt*ouP__jrj$nug*il(rXJJdx2ug#1L zaZvH5M504XZK@8nhoyZhTPx) zLgiGZNhszKNw8f@Wt%SG>Pk$kUb>T6spD3sN=dHFi0$s@65MiV&Z$~Zc~Mm4tZp~D`^|nxim>?s8zIvb3iDANYmN+p}vBO>AM-Tx{TAHma?0+A23oN zCPcD~p3FO}XtYmQ&44Q2M|76j3t|wuFuB-3o}O@JZZ*Fu5RZ^y!dj}@b0#Q-yE|x` z`i)}hyG)*=U?~qzwJ;AieNno;A0*!{*hLUZ{TV%Eub~AAN&ZIYe|;U+?+`|Fhha;wNN4=#hhE<@@-B zl6_HB1q|tzK(BMlCtl2 zuYZ05wb;c2!2Ih6LY8MU+!99?_7P#upswGOtD2>|s`6rJe%w0f=VRk4*2>_q<{~zE zjcZL@!_7iNX^q+i=8uOI(DkB*_d1sHI#@ld5`fWciUA~{LzI`y>!qTjL!t0;^SlQ5{w z9&My_W2GNdN@L$3uH5h4H7RPq7)%UV*69`O=+&+@C6fv-P0ZUySGB&zd|R@6qxMzZ zsZc1k4)g48w;z&x?^qqMd$K56`%=&AWQ&B-0E8f_6+{HF+TFVGe&_Im19H4Zpz#Rb ziHlOKrWSk}7pO*!Hi9Y_KMxy^^;Iu$E-S>K$xfeu!X*|EPS^qu3h(~aNPn|#8rl7| z^SOU(=M4WvdH=({n>3)kl#ZHsPfVI8H;L%<#RNcv_{B&9TM^fOK@s)MQHcroDHSD* zfkBWk+MC4tS#Pa_G^to?BmFKdih^wlj|Y>pw7fECS|7Tu>2PUi%J5b$d-U!0Bu%qw zee8OXyl&ZQ$oXd9NiyB$_ydNRH6^<)Zm5AA>XPgn>=N`L|4JS42h8w}j|$^w3bVm# zVenF)om~bmVq-#(`T9@am>#Oj7%NIks|`drqEv?crM{oOu55FOtJpsol$X0O%O6Bj z7T+mT@AsGJznhuAdZ(Yh2LC+G$q^y)@?zM)52q$)0a`Oz$iNLBMtWe{8}^&_3osbNM9B7KSF58+<_$2~nqE|F=FqbxTbTsU$v>3Knc>B=}QK?$hC8x5GiltUnHIP5K2i$T; z@YAYlyEU(TZDrLDn`bn!Dq4DV9+l4c6+@%^+-pg5h4l?1-IAp+YY;x6s)0Oj(8YyC zv;%Y_qkoQuq^n^XS9VbpDbLIZ7P%}4Au9?S`%VvJ!8}XZU#OI{bnw4NeXM%|- zg-6IwfOJ(Bb)FvWVfBOPy5XFhr#k$xaee4k<+S|!+XhiB#kQs1*hTK?S!LL$T)w3f z5==A06w|X(V4+QTn35W&3PkO12_=Ri6qFV^^<|NbIOG!sY9CdHtfNB5QmvAQQ>s|q zGP7brndCDg;tZq$6ppGwnBq0-tvtK;#j)D_(iiTM06VRsge-(sZL$oX0W@T92rzn!bTMaW~V?Q{UMo#&$wUm}areg4*RJ}<^$^U$QDcNq|~gWq>D2#-MM zaH7aJ;MAUO#B(Ru-daM5A|Cgcro8yk6kxO#tf%J+^$-TXyMor%K_`>BN@9PH%=SOH9W0E zh-N)=V%!1@f;Ev8n$&WG9|0TiAl|ZM$uJre$d)kF_o;+k;e!qCyP|~ju1ub?^)^Gs zn@nXWASN#JmG4Rg*_L4F@UOvKRzABD$C*ZsNcUEI%!C;+5dg?@j5)j;F>iy8%vvd$ zt2&T47B6AII)n%yf6z@~KKtla5PLYOSJDS*@z6x~3=moc9JWK1(V{T{40h zRN4!E)X8slc2JUyuC7yl#p8J&h354(9jEbD&`B=UVQiM@jJrnUvIk%#U~)M#Mif>dFayWXsUPRI}&Dl{dRFEkoEy zL{E(Yj|Xo-T}}}aR-O%;frdh`#$*|=HZ1g5F8XDR(2l<6xmldD6_DR0Z6m>ng60j{ zWKM(?0X5?9x7#(c(g0t8UUQtXj>eGxM2SYAsFK3(P)9gx;g~9MPY%bVp>dhTos}rb zbe^T83%hSPi{Xu+)DcFIN>(p%8q+Iu8Ckaf1B{S+)&InALEpM%`3R#dw1oS{xl zv?%sHdu+d_xz`RVSuAFC=D@$oV?;i?3lY337jF@X`q>A#yg|_?Wx=)lWW$9tK7?tQ z#>ktHRc!;X{2Fqk)pTlRkmJELaCIgH1bc~i)|3lWWICK?(i9Eg57lv^ZtNTVHeKb9 zm&uVUoskwkmvIu|Fm*tc>!HLDwIO9Ti&PR8$RZh^p-L|#_7isFFx^;-%1sww&oT|w zOK$BUJv~gzv^>EoPWt|_c#%2MfM-K+95cw7UzeB>(wA^HnZHh6#a%=I&M||!VloW4 z6JiQJcCl{6q=PCuIoBfNDv6*~)Z?R~s5JxXY-}HNA{ZABv04m=c#Va?xUp5uNfqMIxI}{w7yJ zoQJQMY3--AMA}TBveW}x(Ep>I&PUPyMS?FFKn%LAmh`bPxySow~(^N#* zEVv_qE}&;?vO(j>0AhLUWh$m`4kGw#2$9WYEq}csQ$vO%A!(bW%;kd>6~9nb1E$0_IYN{BQ6XM*9TkGu*k&|-<-s=hBEzGV9P5~fHEAe+Q!_qb%RZ*FPJJXl8$2`? z<9g>blhKTMsGmGQltnJR2Y6$B&$-3YKj6g0=r#hza)CwIOCQ{+bQ513IcSHV+FWX; zpP@5NqI=62f~>gl@LcMIo=%*7Vteq~^L3gq=`RDMg;eQ7%F39H)Z|sSX2mv2!95Y+ zwF~G{gXZJHi9s|4Ip8V%`h7G^v6=?fkgtLRECn6j$I{fAeoMytr_ui6*T^r4fgJim zy7o>x=)si1+;QLQhbRZsRo0 zj(UwuY=eAABY(h}bW}Wc-({pU&W=&%ANOK8c2qcayweP2_rEYdFB3nKpLsogfOGT( zrdFCEpisHcp^%>uRw~bqm@-Y9_k?KVPM8KMX`t7PaDE+QmpQRQ&nJ$Vo!8g+f>Qpu za4u?gHE=$JA-oOf!4%`klrBIeyC~LZl2p?aMJQH6_ZfS1axW4zb4{ja*Y~9%EBL_<>nndoc#D)VP7;$PL z0?=Ixp&9}?r1T}E_0d{Btb^$;`D(+u2)6OpucbK!@M~NGT|B5oOBKI_x`;)gjSr!o zJaCJ)Y3Hta#uMe&@x1l?v=|x3_I@~Cw&uT@2hMi;g90H$}6U99d>OB$TbNJA-V`f{S=eGqaPFc!}H_D>l z%#fZ0V>t6xv=_x)DYd(TQCHgpgT%q!iD&D6DqZ)Z|FCT}=fwuvku~(iFel|#Ye3qt zcMXwDz0R9SrD+E%85Cxe=oUdAIom~jJ_$bErVWoLug8pYLGa~;ZG*&n?jm$axs@Jg z2*B|xQM;2SoF2SI@ZL6ZM*Jz+-O>4Ka1WB|#gxprv^)USI&ix~819j0?-ke{ffHz| z9@Xs^7knq^HiEZPY2Zf&+kJLf;6y8MdYjiMNGO3u72OXs;zt})L>9~3PXK$3i=~VQ zI*_O6fR;VFCvNxhi;e3^h8-(}P9#!-26lCDp*fLr{A$-+a`^PsL+P_UBv*l! z;n8vt-oDYlw?m`VBHsaVf>A!fKw=?U(z_Zk4OhKux_T=`R2eeKNQcHPDiFmzg}P}t z9|);Xf(t}FIEKIK%yEs8Lrr0S(T$sZVDi0)4n9+oA^Gx?zfk7hG$oR0)8(Ioc#J7I z`~MP?Y=47;(-qps%;&NRS2&sK$+%nxQm68ao}GC0-0azqHw=CD3{8Q*+w{dMz8zxp^^2Mjd2#C}~j!OwkT*gX!H_BtkZ>4*w-1Ly4sS+fJ{y|VfpQMbp+6hrB- z61Zjk`n`ooJv8QCyo)pMoZ}DVLLM%I-2pCK{$8jx zNq7J7NF9x7Smj@6VppkgNf|Z)8L5uCBOB>nNerOJ&Ww5l24f$erTTtzaxA}>Fil)d z8d`jS(EQSl{G=r5P&`8_Ei}T;QGloB8f zrOwJsb#^eAkdd}pgQ{~Tp${5P{tPw4Z_lYT51~2;Qz=BqqE9ZxG{TWy#)<&dndyb4 zO@e#44+p&42m4A5$4C#M-9@-a;JzIfS+ujA?BeMZ9ob(5!s zeAF#+F$Mn+5qToCB*ujtJ&-<5-2y4Bj~18)dZ$}2!_K906=Bh3nv~9fa0@e9o7Ue} zV8`Ty-9#5L(+14O|IpK5v1K-xbn8=K@J#)bq-ICGDe&?cQK(=YAy9y~_vHOliT@%y za8bLa3&tlgL@=?Sl})iIDKJ_ezXD}iIiMSoqHGZ#Yi!&}$lTy==5cVp^ftoG#dNG3 zzyit1=AP!U2$XG4>A2Tw z=HI5iKWvM>hqV72wcaZSd*Pa_8v#vcA9-YdDUZ)AP>a55_z#<(;=T+6JD_=!DPc#& zrstK#;2%0)DXN@p7hOf-MUKTug0XN+TE%rH!ReGH7L9_!_7NX2>Z4ROq;rY5l zT4q5vCE)Lemq|C*DVd(zjC5ArYQo$;Pso+R)VNttJFk61>`q_#{%x)AyO1;tXmORK>dv+&Q5m@pl+mX@fCZ{U)`UTcrM7#;`N3sGL zd5#vv)SK8Z;0K3pBroj`qg>PutY-F(^nQ^z;$*?3Bs(~@ml zfWZxqvT9d|k~6WQ^5S_xk4Fm-sAHuyNP^%;8jpqn({ES#jJakp(1MrMdn6_er*FOfoEX$BeASk-V-^RVsHfgU5NWe+RYddmOVlpvv9e+f7 zpL)@w9ShDgX>)qcIPtipQWQlsMj5;bae=^8jqG&9nZF}m}*WP|?nf_-`9!YH$Kq@OK}>=}`K z5!RX7Il=e>X1$MbWBg8~1?~s)$JPHrLAY4qE7<`G(g9KFf&+WQ-Wl!@jsEt(IpPH= z`5F}bF!hPoJt}#n-F;!zTiW5j!~Kx-`C+VajB%GBR>L4W;R1BR0M^@K15BIyXr!R6;|Mca0}&XfpGq z;{-|0fchqa^a2#cIF)=Ymuh~uLR0EvQMi=7XuAf7w1{K8!670?y-mV%3d&)7h4mhM zYJeigM$PPJac#xa4b)Fph(YX4rZ<#9@{4tYO~?ra9vn6iH_lj|dwwOs^U)p^?&wE+ z4(yL<`d-`?gyRwFGvt8>XiX05!Y;9@$M3&j5E3f(W3BTI1qjHI>OX^n|MNr*>whs> z7SOl+UzD~^W`GXC+eS)I5D!65-#`xtBEOjsiU=kt76gueYirTshYS_FIe5-c;9xK0 zt$e6tX<(q5!~B+~kE?0U&BMhfC?9+WjRxG-qLF-Yb`o8Zs0qGxoJN7OlPpu&S<<2d zg@ikK_Rn@2wgKUVW?g1--9{s`oHDJX6S(QUxln)jNg)rr*}FObiy%UU*7j6G-QS>0 z%Q>)4JO~H6qWnSO@JI>;?z8odR|dUvaMGG0q`ckyW~V(tv<`u3$~oIYvDC|haw09Q z6$(8+*p}gi)t%;wFCssd$@$ilauxClG>P6}9W+m}QdW&}#8%Clcpe}+M+g?Yo2W&2 zOuOM#iNvv5N(pN9%fU*1lLTJe@P$5P5qtX;;vWI-U9AVP{zm&K|68>G|Azn!{{xfD z|BKgfgF7LQ{O(py+ZE7Sg_4Ydl_x-S_Se@9h8dbqm*MXjA502^C*fxc)v=iFR;-fUysto5D}Lk`Wcm+lbU(s7Tjoz$1-{C6us zOks;I?FxEoNp7a?T3m#YHnA(pw-Sd`IG+!#RH1dH)Q!el_Z7sd$?mt)5CThNTy?Q9 z)&yEd?wj1?tgbOxDFagmsZ!A-*$h@E{&%`f2-C5m1}OWw77?ArVT*sEcdL~9 zK&!uk+WuQXh5s8B^?#x7(u$a(2;QKS)EcN8@zz#&tHUn8pna)G`1V8nzy+Fr0Yk?p z-G{-=5``$0-^&js3DUG3eIKSf%!noP^~tp5S7ohbXFtC=%f@`Y zojmgbNflV+$LQCgUUGE&3&1-AKoLcn{srK9Cf*onx#o5isP1fST~#OUXOT$Yf?=h7 zda+~Pf0%k#kXyv-Q}rl}a#Jg_JYcrfuu@{SH93&0s;_P4`un4x1t){@#&4!5Uthr* z?!EVX8@0Y+sF0zo;Mg9kN+e|tqs53Z4Yb`iI2aozVObs-9H9^NdQCZctJ2UtmM|(q z7_yhb!v2nBh%-Vv@!^KsY*&Q!BKJBAByeC6LcR=PMqFzRm4pimjy)O>3)c<~b1HsR zky43+-)IF?z=;bSZ<);;gj5Iw+fW;{`T+EG+p`@#1X=v$J53ED!yl8aS%wi!=`Q|a zdH2-Z!Mpm;t= zW()&=XCn`UjoP&NfX!~mu|z_UMX|{aSCItkJk;i<%Aw8h0Js?4K)?28B`H>**i0Y7 zk;DYOx)VP%D&`LQ$v;_)T<*!>Oo0C>0o}@0RDc+zrfDgO z9_RjKOF;?19V}V0k_yy8-sFf-B=i zs>hkF^bw@B|3XXg9U7%={gV*9nLC${-YV(`NAg%M@nHgaJzEK7kkGTI@*8y}f;x0- z-S7F%AHp}(3G*Y?g|dHjbx!VK1m`vp0=|W%vG1HRKpgww=XJ9=mjsW9h}qxxmi9kVno&L?OSkElJbc_F@c zLQ_>gdy3jl*9!udSXQX6Ss#C1a2G8IPvWzIdQ#?LkFw@nrjnHMI z1}XpP3kX&F^oxIqelq00Ys^CbpKRnmL;Vd2Pp-L5D+5J+~)eu=QCJq>Y+Wa zyma=5z#=vpL^p82&@>>lP?#j5=q)FA#R9#4_xffusY)E~J^J=A>iqTDv`+e_Y1o_L z5}7uzbAh7uz@E?Br!1dO95O{xIyiDdej$IR_H(GY>2w>idrukPP=CA#La;Bd#QRRn zK`o!4(Vn$F>|T&D=ZI1kuFb%^F&$dLOp)ojMxT1kt&Yit0Xn7U0e3-abo(NABOey3^F8y1$^<17m^s<>C{}zz`9^prQNpXv73s&4y@Cyu^QI zN_tF-`!#K;{Kdvg8k$vou#FWa)^!;vfp}Ri>*g2hRZG=PRSm3LDlN*^Egl2)-|s%8 z?4+?~S+Ae*H(fk84<9+-4_r5}vfp2EqQI50);ef_H#q_o+z6~tHRvbJ9JKm`GC2ms zsAbpMwXzIpm&NOb`9C6-IlpQ8D!Z0rm2sQ)y`QkTJ+I_6cCD{gJ4-6`8*&SoIXBW| zME1Qc!Gog0+$gMv7PeHJX8+*lTudj9bZxZ#8*t&^2juz* zN^fA8nUAP269MwW@}jV{wXv-&7VS`3TACYew-@Hhg73i`Nl;yEwu(tuSeSIGi$hJ% z4$l{kH!~;D*B2VSH3d~AMLACjW#l&Jx3^~3=NnBeMI}8~imlyCUI=doITkRaeJd&H z(avuoNg%*XDwiaRHhzsSYB!dt??yO3{wcm>>S!?dk>_MIx}6?MhOE&K2v_T{4#9lC zn(aO&+iIPIx!Rbs9m$Aa_PO4n9*toa+YoZ@BPxF<+~eWQI>6D)JBxNkvhJrt@n#-f z+gEdGTPon{*#mXc5m&p|%~e0_AQA#4IC}zgo~PURSI{Ficfo;B`Sz4hJsYyCq_-kG zaqa!0X31G>9Sy_<*U|d;j&19YSyC%2)H3K7!oM?%8oc_`rxufS>2OPCE*)5D){+!i z>SIz{_ZE<^v~VSw?f6FoDMGGNLz*O+qsj!y^dJ*NE0|iR5o+1D_CQK2T7}>5Bb8&% zj!;gU@dsxj&J~FT!lNN&YI8NhB#&WQn2eJ>K@w$qludCmhYOZqA} z!>X)wYWn;i;N#lx%mYCSy6oy1sp_W#3azW55HQ#owscDmLhk_aEMck3KITJoaM)#) z88SNz$&%4I(m$zC{&{Kw=2=cX*wfHv3T?vbOP2XCLpqNO0M{^n-nUxSI~UxA^GT0S z{;@fKMiT?{a%D>WeJx~!Gbdl7At1M&N~2m-Vi`%G?b$%xaZ{z_QK>sulI;v0F*P>h zg5r$`P(VX{Qzdb^iYR8hcL4o)?HS)Fj@;w>ul!GRbvaH)xwog~ zg#quMgMx8jTtz9~28r^)Lm75dWQKr{KTYbOX?)Km5bnq-pLR-|!yj@A3DmHSwZR@d(K;Ec)xHa*QHsf)e;QknOC6?8^V0m^sMMl zg_p-WjUK?a$H5`^1w|NZ6nHvTVebIM=8 zD?M1pr@4xZK4%9VDYPCGk?eCDKkCAAb{zpj8WYi(a+jxB8a_#pzRY)k{bE-0ke|W% z>(e=DFKzuSxx`GDEq~VRO;#1P zH`B+-kQ#(5_R!yHCQ*fxBm|i>roZW;Op-2cuSj?Lv$|i&402j0XVfNfrS5(I@qZSh zsAur2Sb!zuABcm!_ZjUT7>`l1q%QVOJd z;ByRm(ky3~DDN(0p2dYOIzQ{irxaEohf7IQpf;^|Q9&z*Ow|P_oFr*Lj7ts8w<(-c zke`gZKYsENB-x}@Zeibzfe@u?a!*ZZ(znY_vl_R-l(6HI&K!}*^l0Jz_AoY*OHA@#)++K zhly-^&Yw?8O0VTkXIAY_q$Yt=b2KwAO2dUh5V?C9Bt>d6_G)3I5dj;-y6_U`fhM!)%|b za)t3+*NtYd2wh&6G}c1d^%ji<>_KD>fk|YpR3ih>G*YKd7v8)(VCEQ;N2tabepSvG zjBVZ5Zm-1-F@cR8gw5sOt?dkNa78a9g7||cRJkD3)LET>1W3Nsj`&_*z6C)WfzZlc zAU1anR4buShrE$6>I!Y~O@)2k{?EYrp-pvJ!8Ik6UibxEGbDXt-Larn6%|#LH%t&| zt*`1>pW(}>p@emnWcG4Vpau}+%QN273)Is~DUW4!gX?{z!Uuid?~kx;a^Xyqxe2T% z_j}ng6;ZZo?2(+R1J-oaG}W|hYG=cj2)!Y?&TYh2uMxWj(6y2Vib>+jTs>UkWcgzS zEy)<&6hnutLJa2@D)#=!apjgc=3o^w`bb5Kvfl)7ygoAIXEG~txHfIrIgX3YEsA8I z1Fd;3Sgw^7AN3BCK5V==U-3>Nv^^{Ay3D4T6FXCV>-3w9;j|Ez55smj`NIQdw>b1P zFcbkJw?`ME_Eig=y8QBX8~BM}5A`B3XeP61t&lpuf$be_gmg~S3hcpziM!)_utx$o zMGl8Fm|3Flo?x%%4|E-ed$c?BuwtrRlG@n5hqjll_0lBGo#1?Zp=Ru@-t9j?ZIgLQ zUZwpPUPSrblzs5N5hgpeE4V67qlAHVMIt(tv;AmeZ(w}&^KX8*1s0>oJ3#<3wlNlwpB2W`s`wxzgLN~f4YBQk1?O*J#JYty1oihZk>+>z zH-+PsdrSO{H4+1NNk)q{@DL5)MY*(-dmF-N*!21ZepGzH_BG{KZ;dts;fmPG;2HyX zLHlw<`)xsF+miuYf={y>$up{WJ(j^Xn!p}P%6r2x9@Ue*>b8+}c!AqFcb9WPw$jzi zQ<{|<);s(i3q{z9r{)RQGvP7C7Fj;#Oi1Qjc}w27?Jy4`TecK}l?4TUW>N{7qsO<& zSG*m#^g@JT$LP3R!sZBD-Cv@ApmKTCz%>QbisQN~vN<-_;a1X>;R}DDgVmRU77u^R zEgZfJc0IuAw&tB)urr`~%QfQ({d})yCL9Sad|dm2xx7I3Vcik211mfA{(EC9*^sD{=+Kl3k9}lVNP`c73*wVIB=}&N zoL4-2f>~ljjv>xE*PKQa&`cgpqh<5LeA%_iByZTmku_8EQ6W3+Pt#vn`iYrB=%mfP zIcdA8U%s#}EOwn_u3C?+7|G-V6t?|bVa5z6$k3-_Nslbel|G=|_X(x3EqchUp;)+b zX$p3!9_3Ct1t7-S*2DvbpCBlI&yLpI&kA|igIj{qkUG>m>vc?a&xd2 zc~hWLQv3$f0(R|1x-BO4+6Yia%O*emb-E82TsYeLDgJ6N!Zf(ff!xol64+L_z&Vp*V>tG%e& zOlPZnep9(>$jFLk%ONPUs5QfX-usuc0oBCwZJ_3C(w!%%=lLK(kn)f|NjuDTPW|-K zGp#ysjuziG>(M5CI0-dbe$vnyrwD$@>--FsU0L^bY|=T<3Sz6a=tI3;`8%yq1F9v* z1%m-&|HtOn`D6d)0&K^}RuDHmxs{`cTG{d?-BxuVPB7<9>4j4sHoJgi2@Ewpa|**d z$KnT-cTs1NgvS8>q(ga7rlB5~tjSAj>W=ild2pQmYU^TT?NO~$R(B>A3Fhk)c3<0@ z0m0J%8Shp5{w3w2vz%g!i#qv1N8?T2x{ztg6~eYxo8w`{Nt*pZH?k!Zt?>0SIUu z_upkz?Em%nLWSXfVx+A9ZP;%y0}>u*F(8jR8=1cXfWV6v|IVVlzH~)HWImU3B!0UK ze=8c6poRbj@2q#+<;~c@$%&g|djrI=>NKZ!YT|+wwUqFy`F>Q!SjVVsiKCr3eeqGt zf*rYt3n|9jwB!oFm^r=kQb2mtkPJ6vXsqPkzk7LI1lMT_IUf*^90SgT4p2jPXGDKC z_Mv@K7Dd~TI#oI>=a>=FiB{^fL;F6U6@}9=Aay69Rd!xk#!Z5RH=r=fhfOfoBxCGM zJ{tY06$p3Lwqtx-6B*|&`wvR;ytQOqg|G(y2PHxLMM;usl*<1yu+&;zPB9RvDc5;alsUWtg2$|C$nMFurcZ`T2ZQJmCUM!cFf^7?u|4VkA{QNxKqudyp^k`&nrK zyw+Jb)@LIJ2Z!sak&_KhKZ<4UNcu&B1^D-SafbvUv*7;WIA)0;Rj>?lE<_2ZHYCxH zZso0Vk5NWs53fwe#yBJJ;d^{UrV8 znQ1hDV^t8h3Rkpnq_kWL+3?xaFj*;YV>*FS!6aFXZBM_icnfIyXX3I@Hj60~I68Dg zYJt?>|0p6mk;yBD$IF{B8>@j-@>F^0#t>7h_f_e1HV;DrkbJ? z#t5eFe^J_vSXETVzskD)TV+N6TLt!ip-S4WNXp1tEm=nCV~w*wLjs5tWa9k65W>Vw zsmFr(<`vSB5hWfOo5hNxjOvW6L0`Z+AA~$F`y=I4xNdh--v<>Fj<)+?6hlMx+K#-_ zZKgTi(_5^*A5R;MK<-}V27tWPpB^$Z-k0}$-ZFN0{iWCt*(X->^?51Adto#w22s8n zbg_mc2&ln&$s#?IcyeC9G*h^pSyM>+3_5 zNM{iO(6TO&*`uM3R%;Hn2?V zN9!jOOxP?wzwEWg=50iy(eQ@Q#a5qmy5+|9W47uUWT)ST8?w+4jOn=q;ftL@7 zNUX^$Y3o|Sd2kX~-DAtr8J+WIcv9oETktZFs5%+`9qpvXRE;e@9_3C!OFsX8*MWt5 z_-C{eGq9_!X7Dd3Lboxku!nEQzqyeYo}N_?#fGeQJ8U!~JJdj}S7V4Ppu~wV&YIxH zWVK7cM}z?F5kxVnXiE>$Y_;y!XY&>Kg*h?0Fk}&qgTfEBW}yr{2>8 z+@dEYKTMWvrDv06Sq@|T)!(keQ-K!-TcJ)~nMYDw+mvWHOon&Iw=!L0Vkz?d@fMwg zLuk#ytV&h8s2~W5$X-$#IY9ft8!?#+N0@-2ZmjlW{hUA=cOj)tOp7FJ4(vFNFW#ki zTY4^A2e?XFLsKkaqfH8?yg0`0_;=PrK}HIclGWMD`+Aoi`+&&n{iLo7Q0Rs*scO&UaP;3VPMN@%)gSs zw^238MIbzedl~gAJ&ko*o1jI*2)gxo-uysnT6|k!E4b~qFd)|Uyh)acRqtL8YAlkh zw5wpK7vlT3zn*l+z2(ZUFV26}-F$KX-D3xu$y;=mKj-71z1Bu!o+PlBK1^B~T;ys${<*U}ljS3q$f+fz1VWfEvDjTpuyVrAwW-{jdJSv5|$(tkyG$n_e#s-nES(! zhySoce?4m~M4x*Ofe#@jQo7Dzlv7_$H{hm2?@#9%9%Rsas2;OIW=F)UmUHB31FLEU z(%xL;&`Nv=D3Ny9`HC5F=9MTS&4-+AS0tpCOOBk;>{4BQc6GJ}em!d{te;dz$%>r# zMdax`6@n3oZn?RYjZK-VEz7O-SKzZA)>F6n!SjBB)b7?=8b`N;frg#kdU&BE5{oj; z5)11mStWFhC6)%|sYPi+g>9L&S;dAP=K!8B(^@=z-fciyZu|MVbhcR(AN=C;Zse3%W^ZjzWJbm{pI>k2_Q-=!6s-knTCCQzZMo9Ix zC{oo25j4kfBiC;c> zb*1O`ek+7F{S?(gbE`rQN6A9r(6XEDbM6-Vlg+k<8p4uBI#C?n@G402p(Ynuo-|*0 zFdK*2QP&Tg=JtY#BRr&{lmJK}RnF}VW)#jSHN+iZw8uTs`l(y>@{U`ciwk>atBTLy z-UGv9D}Ic=T}Z56x$87$ukWuX;~?DDSV)E`8UL7}yn%OyKY9b}`;c)SVOl@#Z&KG( zB~O10lE}fT&qTI!Y%s?phE7FNpHFkrr-I`spVOpOv`gq2?KVsVK_A)$w`0JcT6BJ5 zOe}9BP3y945awo4eAum0SA1lFrj!^ldJ}&&jhrr$8Mut=L~ta~sP_VS5@&8&Rmx^k zHB}9}nnIx@Q|0LiIdC;b&NDh6s*i&p1S-6@(6mVqT=!t=~SUJ4%K#O~?Ql7i0#~;zq{Q;Zq zvfT%?!PeaNY`{*?z`;%fpMCE@OUo=*({~RgJDq%o+rF^Uu6YDDXY>rIaZZn?3Hh0o zk_97YZ1BD1jKSPSHcSQs^-wd{j+Q7wrdSD))upn4fxVj<6?ovCS&oMt_X_Md|7*&f zLrB2yVf&RQ7Jg|y8GSSOSvCwg}cx{ha?Uzk0FJY=#UYwWUk8k4Vzc4 zNpN%r-uDthQsbCKhC?+IS5?oJk>m)yg$D(pbk?WEj5WD1EqBd>u+!o|IMI?Y*3%S` zH;+l{eWifYMB&-fP#!MP8MY2~b&KA+UEPmdbIh+3rcHg}7o{Wf=OGpQoU_e*v_P$B zwl^aOQ}A}FDP^l*gX{fJ@4sqxk6=SP;LQnUXt#3$>qk_Hh}s z+j*zUcRR0Oa87K))AkEhXx_5{c-!g{$l(u^Y|D}s5|-;v44m`K-`1We&aFVA_V=== zK6wU*FsZa8_kl^igWkN4W>S*67`c8bhR2e`!RJv4RF zZRyjMHU}g{j6A}=wJZg&0p5hh@IqZg49&}1+^V)BVpHvh`EwfAu5n_^p)M+a0M^VM`h1cTM^sl0| zkf;K&)vPfFdO!xY{b?qNn&w|=OKIMAHZ0ME!U!CmQW`!6fV`A*CS($0y>0)N3=>^< zj;lI=8o&z0=cY^+|4L$(`nH6*?m4BL#Y^4kSL7!4caNG)|=&R;BDd_ zxhD0foF+1MpD|ErT)!#zCs@vVIan(17Jx;KB{?Kf|JaKhlucqXA|R?9ZDL|nw17}? zC;(u9P}%6_NCx`hXFw~kiIc`r&+l6$tg3&oQl?0JZ=4bwT@XeRXFtKxED4Xi(O6qZ zBfkg{IUo&d#W?K$5Ubv7%0_)mjtKY1f##}Fn6QWBW@eypuq8yNtPKBkT%$pWA?UQc z#_+=mMygamMik(qk97E-vyizTokaI?CCZ(KMM^ zYD+N=l0t5YnU9d$c!?h%844V?9AC12xek({p8H3Qk;8+U$q?Qmgw6K(AN=-pZaDk3 z8qq3)w>w$Vj`<%Epg_zcWX|lcl@uqK+9t6EBHE>+&QLV93TEcX0@CYz5wRAa5+2Ua z5o=;`UsV%ogv*=Fl4T-+3-TINy=okseg)k3b+CQ>G*g2^M;OlziQ||t034_So5g)N z;OL*W(1Ys4T+-7zEiI%)wA_Kab4Q?1g{}tH#64O@ym;{BF6nmHqnPg~0qaCj-{?VS z=N3s6zHS6Ox6$9`{f$d1qo-n`sErB-kk~WhStquCvRQMb7}~D5LJca;4!CiV@mh05 zenQO*1isPipzm?5yIPdEqEF729ZFexqncn%&N2+T@}iLpG|jCX3Z`7An{ppwxgW@l zf|4JGn%dRHUd~57B2cd}Mlf~yg^53b10yWPU97CgPYmBt$|MQ~SSI>ukJ_=rylm#sT& z$+TCV9rqh2zA)|rM*idHdiokNwK-GNsiuxb%sV2&0P%_T=ZoDWL{&8 zPr_RH)NIXX;!>_4s#e3#ee@M?EeagcAK1HNvbUEyBi=;Dox{<_Va;qLqedeq#D}Ai z8L`4Q7Gc)7_upt$Y)0=;$kC8zKnFdXc)fo8)@h&J(y^kIwV&)OMCUM&Ta29%(+xhQ zc^Jp0GOsxY9Gym@ehYsl}>-rix?gn|lh`&HR{de_osV>^JbcEBGP%ybI2 zb>|M}&JCPArk{Tbr1IqO0KYrIpME@!-t~R=+Qrxf+CooTWZ8yE-Kmq zV=gyu4eVYSj1O$zA}7F@d6l)|ox_v~cGRTH8<}T#8o7!q$W{&aY@gtyE2TiZgMEZ} zq+p~p5E66mY3Y;Q@ReO)w-VBWE>HMyEIy3M98s}9B?w<*roS-86C#ZUVIwaPED?v&fKD8F6F3^+jn&#KLf>r?%0~~4OCS00B)4F1P@E^= zhFio6WG$OR3mH{2MX@US;m)V4YggvNny0Bt@)EPAK0|OHV=*)C!*ND zhA?1-_;+Ptq*gL;e2m;YvKd^RroVD7Y~iVinzC)t1RcrO?OF$1!z~%)WHmHtF`uDe z*&uPkg(1O*aKf(RNnv~qQXv}Ok$TFJVv1Z2GNu@zuM4?6?wz&49Ih524tvD4vfP)G zqF9-3uYKQq+h)W zqf#xH$BIdz0)-iOi;!T#7o|4hgWPvfe1MF1kKMzUbzl%AHLP&0<}3IkP+&k(>Ri8< zD^pPILAOPc1EHT1lV}jBrFFRRiC6p07bVCbH$g=+)m-|Q#0j)CD&8pfp6cFIF1>Ujj)Ss znaNp*m(RoDbBazT7zZ2o7x1g+)L5PCtJjuukN+IPMXELg0EpWxTxowrwGjg!5!p?qM_3>-Yg{_RpCc$~6dt0?Xf zX!=93z2e*66^lOc>><6b2lwlgSe8~5H52bFJF#X0*K>+!p0uF%L9|oq4Jc@uR2L-8 z?Crg6F1~IfUJNd1B+{~@*e3p(37w>}XwDuZ^04!AnHE#8i>OogLEnsH9FI5 z<-{Dt2@?Yv!7S>*{2Ii?QT8VF1wFnJ`=q%o=o&cPSPhs_H&>;JLArdnNl9Rx*!w4@ z)i|~QJrU@ZuPNgZ(&58eIo4708eva|ZmUoVIc}Grg8}l};nU zd6E_JHqAjAI%KPE+0dgvmnYz^nxEfQ*C)fDdJ&Tb-PS+gT)wR8%mm4OQ-_o{fr@A} zqsAD}M)a;514Lx86zv3ms$ZD&>{89YFzBBVbrRqaM9uQzDE(RIy+%Dt5iMkBRB3(=c&{0bhZJCZ-a@XGKJ!q$x1(RqTVmX6c!>mg z$BU(UM64$>Wz6!r#g>4+#ujZ=F+7Gstt=oYO{I$F`mu&jTHF>N?4j_S3kAwX1VZ_1 zOrV>;O^9J`n#gFG%e1-HIC(vMl4$QE5^3LK*$SVv7oMM(!~0xY6M)G$ z*HI*6Czd|whc0Wa0a$CTODmQ|KNmDn%W)xSG`!oWY zPZUc%gYID;W14iDWwos#NzK0!k!bsSE&SB1vTK9TAdsv%r4cff|Y9koEp5O z;fy6BmtBo!aJG>{J3g>$x8|htDSWu$!|STG;K67F(q5%=qw-pWoax1a2bk zklbh!@DNMAh05GjY@+c-$8V6DwY@1H5hE{>4qZp+hR(l&LvIq9v4_pOpcPNBpYJL< zv1t3s&+Tobl|xuJ;BKNi^IA7#<3v95HT$+2nQav)!&ocW&xIWzfCedmd3~Ec zt67fqoRvoD0dv&_g^EXkHR7q5x>{kqhT0;H2X$F%#FM$fI&MyxuX7uox>~TQU7Rp? zVjl-r)Hgp;(b!y6avD-j5P~fqy29F7D=OzOYclEQ#1{Sz2_(&m;gHAL{h;fq$&$**eh z!Q0ID$Ii}=Z+0j)Fa$x@v5Mo$GhzhxMOY&2MnrWv4P zW^6(?>@DFowy)JSUAo2@Qo(GS7mgtv@n`w|M1-U#hQ${4+ZGz$2U(y<-)l3z1&=)8 za7lWvikjpksjV?+Npw{S*S8%l!DD@D$5gngj>RMB9j@qRT06>2@t(dalc2qHtEk_& za*YYk6K)_AZomi%5x*qw>t=JMU-Kj@@RN1ckb`BoOMDiY_8>Uvfr4q=fUU((Wo??P z;w~M5{xw^osdUn2&6#En{|tm*&#|ag_)8*`s#` zN{)^JIOC?hc85uVV<+{%6vq!A{qE|weQ~5&%tntmSN*g2Cd?Pz9b*Qd{fDucMCUd! zL&V&&K)l`%-DbX4oN|QbC!y`LgM*cHfa7>qa1$kC$H0#r=^({*ZS z*g>0X$To4{Mo%tz#fxAB+SY@Ij^b6P@s&j7lEVV&R`Z2?pxPm<THyzrV}N*EyN^j9yToa(;cKqq;OWPE zx#Bz2xzFgNRL&8z8L}x213`*^@N*wit$C~wRD(2kQSv=Uq14NxXgEUZCGLyx5gMYQ zjOeU=&9T4GBGDbQ=qW)9-muO}AOY=r=d2;b;L!@aeIjhkD?NKUsp=ND4<}gk_q>Dx z7E*pDD5uc30J81K%N-%nIRqhlz^_g^`N@}8mXd6_KxZibJ&LPf@ z(FgmUCaH%2qt1g?vxhtrS&Ii^;gckSP==bGI(MZq%szu%_SPV(eJ>`l9kuflOX}Rp;i^0Is2InRs}=baK3x1UEs`5KrtKm zQ)x5d_c3mzRHJ06`@f=%6R{6M@}~m#XYrwN?!rha)po-6ZK{~z#a_d$o9rX82Dzn> zq|~tQp)OQ!B&N8Dw!ngmCXdB9QUUoMv)7BsUMOY{jG*t)KuILsL6;?vj873O!+Fa1 zRK(?z%C$^S0<4;9w?@Kw^?WKy8t_A5;+8RGI_6%XmH0gClH?`79cvo-0^+OJpy(*k zvPP<4eEgxpx8+3-=l?hs=iV;jWOPDu*g89Xpl(tea+X*#6bFlT7brsx*jQ>vEixDM zb?I5aN>Nl50cpY;G$Zr1!%~%0=SToWIMBEZg@M5cDyoPPTJ+aVj`?o{wsEBQ~j|35G|GXrTOdLA*8&MN_r;VA_vwi`#@UHG@HKn*5759uI^ls=)uQ`OQ%~TVO ziBImr^e{^BHU`PbK3G%YK}XiJjO!FZ+0)PMdXf$MP^070qptu?_J*DxnGK_azPNW7 zAw)4if-_z6#ON~d*!+4KJ>nB-iLO&c70{;3(63Kz+B*p-7s)rvx-hMpCht|bAShNQT0l zHy3B=o6GwNWU(O*C#@W3di*U@w1f{-V5N za6oEhO1w^I+Ik>&~f`H*n5yYXL8jwuACe9a? z6W9n4XKI;}HUs=1ouE2vlp-k^@wqkQR{51wbA@yCAkBNL*f0m^hB3|dbZpJIZ7rrN zf{KxdN`?TTxNRjDw4jchQOQX(*KxF3eN7skP&V_e#9%T@$~O8Q<6zs%v=-^$#fC8j zZnm$eO(oSglNdJoKu+dDlKPd3uj#zJ1(fDTKU`7hkImGi<#gZIWP+-iq9#x0Nmr@3 zJ!Lm#%{mj;1;j?y{=Y+hpR*jS3=A!=Q{9%1OVjYDrtMq+leMa4-Ym*?6Y1CFOz#+= zwBbGifMQgRi>byR!>r{#d%*@oYhyJRNR^F?rFIIpe{T457Dr;AIwlj{l`=-gSx8|O z9YFC%_;jiyS3Hs`>opHjJdW(SB=C*ZhnBiCR|}U^l|jI>-_n#Q0yJ}(7A6)Dqn}i99CP99zE9G>$!tr>kuo|T~h>( zqEiVylIP=7ho1d>k*yoxO~8KO#Ee7sm6&qk7LawU!_9e(Im_2ZznS^unPh;YaqF7=Nr(%m!23`g$wn7=_j{~wV5 z(j$PfP~$QyJXH2(+W6aESE+@t>q@d;x1$1s_kYAKNz0eUyQ~mLr@f_j-AUGp4q81Ft9RT~Q3_PT0Nlq7dneFvoI|4jqIDuu^a~g0aBf-!e>L8_YDb8T!mz z4OM71!wp*c&CTj=oA!l+s_22H=QUz-@b_5Ag+hMlB(NL;kRpSU03fwOyIAM~5?tnz z(;oGQ9-aohBv<1=&H~Q|wBCzEbyKJ3az9&TgzZ5Ioe1(@i&q%hrkexjzCH-iAHioBGNbrm%rI1_s7uw=%$9B;lwz;q5S*EUcAt_ zb^}&^`7^1-pjn>sB&kw-tL7AE@_L}W&-8x&;iKDl z(mzmdJp~IdyP)K%A215Hxxwa=23KjFlJ9Pls41efIbsT1=g*51UNZ+|zV$E&13#wjP38Rrs_^+t1`_l8PgGWv-8LTzjRcH>+>WRc`Ki)^W18sJS?8F1{ngs z*f#v@lPpDqPmcFi)Y*)fs2_avrCXD(9EkcPKhmA0pLZ6YMxio(>w0 zcOa6+PzxbDS&0iL*Q5y-qb#5S;%|SUI8sT&wJX6|_nN%P@BR)*mB%NO$dH|}%bs^oqukcZ zQoY-xd?qVh;4Dx0UQ&xd?%HnE-pOn&HJ&-@Hel)|+w|axt=eilur*snkLou(l53{T zIMm~4Qy&6YHd?y_$8gElp)tYj@Q+k&h)t%%Yg643c4W3?7G93N?yFVMgL;HUd` zl})&GrNzP@#3RU=QUg^bF!Hiy`wJ{PlG5Jf$5s)dM9@X)bMykR8iO%S&x!Ivl3py{ zB?;xrw?ie{P(rx@2dcIr4bPk&#x_`QwFq2iCmmQ0pRbZR@h{r@Pvqp@HUizJ+dlt; z^ye6QnT;$o1(uk%{aqoJuwzep@{kRz&x;UPdrG5&xu^#5A_p^T%G-&5iR7~emK72k(g_rCeKOrS|Lg<&h z9@lSqCSR}9Yd=387kPmydLjw>=vAC3m&59JC`+t6n0r5oRh-j7hapuN{cO|lEuwi*3p z+e}n7>d&Lob}HVK1jc)m9gz*$l^9;YuyLRj=+1GMrjNU2T9z+x>ulwi$e&TjTk#6^ z<+;RfcWDx$+jO8QZWMA56@g*2NP3oUwIf^Wy9H@p2B)AgVW}0ZW-4#Hwd&7PpEyl4 z;7s084;E-l9JwmC)`l0fPPA*y=Qt3;)@mJU6Mxo;UB!yoC3i1L%F#1oFMDWh)uRuf z55VVkfiN~v|0YAf0ANeB`Qq>+g~yRsqK9Os5u7i%S;T4XDcepLqp_J_&T$51oA6@g z<(vtM&e7<~04hS9DlncPRE7H&(FNMN?OGfm1ZBIK!qec#OJ-`em!x@o|C01kWbPyH z%snKFYb%Y%3);0U`Y4vm=Cd}*EEHWZ4qyd#!42l}?wSd>FhAy6h#qxjcExnw%bya) zF`J?=zEfS-n9^Qi$eNQqYwIkaKErHk(4RYZw0*{GJgAJF9{?r#!y(wtYhX>Ppij)7 zC|}5MCQu$bg|=o2oBHC>*Pag>YZK+VO_vUWCCcH$qHQ+3(EPr&v}Cz}EGh(3;ShX2 zMW6T;6w3mgX9|`Hoi}S8v&R@@h-kLqZ8|?K6FHjvrRn+Oh@K@xZARDj8d8fW;P5Rb z!_;a_^l(Umau0k-!a9Ya2=x-_%}p;inRc*AK8KDz&gQOLe7tVuIt5PQpA*E9(>bjB z!4sDV$l%iVk?6*P#KVSHStI{gEm)*O9j|9m2bp`gOw$Q;MlbC z?_wraXhBVPA2Lw{*twQ;ee$eg+k(h;K?1%=9}af}pkh?gJf!@uC`aG>L&bWqHv5Mg zih4tKxXM5Hax!lhEtLebp-!2>$Q8EWDc?*XW_hOsp9EXcEhOz?PTGaGiDL10!Oi?| zrKfTs0-RMy35$Y;9d!~1$%TWU-8{*gX-@v#xG~WD5922I-!#4PNh3k1d`-_DcgZ0$ zQI_zq578}iZTK~BjCCyO-sq{)1sRPZhZ-#sc)g#NO77+8oA22YmggJv*HDZ6_&tt; zOT7}4@HZC-;>GV9h25_qcVNC7@$zNCW12YXJoD6z{?zc~dWmW`IBGo5HB;=BY7Su} z6>1k?nztc!yqkVli%s@xz0BPK{o<8d-uXo!>#v||UHIedIO3wS)lM!PA7JpmzW3_n z<>4W1Z1s<4+272`MU_Dt& zXJu|<{2u`rV~2nA<$SHgnVZk}?0Ece{6xa1hcO~0_yM3V+C~K9AAg9vx6)A*L)KCdJ!Ozfw9>h65Hvf8qKzOYiEQLdtG-EN`$;&J(z^$8R5 zer6gu*0$N|{dj+{d#met2?@lc-VU6uufx2myqd5U8)39x$hwMmc^vs*G<&>)0@6T1 zzrj>p&Y3aR7oLPYg&tH$_#U(3L|j|^OIVZ=cbs9Vb;ThD_q$s1Lo4R1yZcVc+K81c zW73$(p0^|;k~mXS%R=AmvKeDzLbgx_)tGv?mSvdet`LQRmJ?RUYMiz`N0kzx*~zFa z+$rNvu5O7MHnd@(-5V}>nFkxd;L!&Mm}XLZaCTj3Ae@cGvAD=BHDW&Yx*fHSb|>kY zH!H(z^0*jRGfRwduKuLMKxASYz!@<6_gFON&AQe^E+H5WnTvz|MoP&)e1U>B6Gt-hkGaBfiiK znpG!t>}uIA^H?G0@fm50;w*FhaL3U(-e_l5Q^*k^j<+kZQe`ffBFS74QAn{yuj6kO zRdBJjuSw}|Zh#P{I|(itMa?s4-B%;C*V432GIP!0oYB`7jvemv7yxrhQ5lQmi!Rc! z_(*fJ*=kKTmH3za#3GVN{4@4q1DoN2P}3}uhf7pfBR;}3UHNp+n~T&dHXVPZnR>>W z@cwSV5fm>by*x%By|fn^o@S4gEOhs|{PQw|@SGh9luS!cgFtQ;bGXmFW?0RdIc->; zex7y2Uee}cj>QI|&q;`WHWGxDlW(MQ4DV8QfUZbeP}hkkX3Z!l2tcf`SDLoN?(e`) z&gdE*Pf>2je1@1aYm9y@!bZL|X?o=aO2y&M&rpdpGk?8dr7O!UGlH(mbT8TV*ymwc zPIS9|G>8gLt5@_C4DM{z9|$&!Jhgs2v|xOVeJw3CfP!#5N-|qNh?{)b)8EwR>tk^B z>2JtEo1o&?=zR)6@RMI3N+RTB-B4mR8nh->w{L3Y7_SkEk zVT3p+lO)z>L7$1)BNC46Y-Yc5-X5od4E}8R!Pp?kGH^e}_Qh$W$1qvQ?36{Jjwub0vb>ZMs;lRRt3hKV z4R4KX(qKf2x|x$ho+4gU-B_76kj_4d;b;n&amt0Fjv_QE>WcKR?*x8ikm48+CrU9& zGH7hzfzJ?UB`iG~1cup{&ka*ctAU`FESP2788T0j*v6fa_fvr6E)!&P7$@S;QqVIU zi|xXoG9%4C`IbO|v7+!KkP^F+zjt`npdv9`buCpURz2Vc z$lf5nu#CBz!*vIaQz>aKI{KF9kXJI{RzFJQGj~n8@p6MOI-sB^t$<~F>ME*&W5VI~tqPv4Mio!^{;kRzAcm=Ons2cX&5=?p)AcR{*U{6!SRzVCB^aQ#sSt znW@(;moi+wACVg`1B;nx5-NW%b98KbMo_MWUk7{xo*M=Y}U}}jm_H-)S$D@M$4Qp$%HDow2le)63#S-`&z8hVGtq3 ziZ*3QgSaFD>IPULact2j^J!)LS}L0&Pl`}5UV2KMrIfIep{7ygF)xgUH3g9+&A^Fm z&c}VM%;;BM&D(ytkr5AfRzhSkjmg1Katx6hAYwa0sJQK3CVir+>cH|2PH_>%Zv4-$ z)6*@DLdL96tAj;jYOOAo)6*Rbimg;_AX%Exhl%e&#&w zQOtx6uWU<^ZpZ&tbmPrX{g#TGY*azMMp9%=Eh?z(lCQl zUVF#w1K1VP3I^m4)+{%$$`AIhrqlH!G_WMI9O^FmPx5YHmi zB(S_%QLLLv<_W-M34S-8r^qU|QYi5UTgmmoA!wcCp|A&`X%*2wzR=Xi3y4y&SpjdH zM4bVP!o6F@1dqDl4QcruYJ{y#-BTq2&tB>^VIe#xETkHBCaXiWd}F2`&(xJZW|We&FwYEWjA;m8s06f=g8|4dPipDAzGCN59(;hMaEv^ z?lL{Ks*dWoM3unvII(9?f|0WMYhzW|0?6_!Y#FIouP3!I7C)=i;63Razs{mew01eh zlmT^L3JCjL>bG^lnhJuUP~f`GM0~XQQwb#Zn5uN)Xw9$xEcY#Q9qC3B{(QxjndsyL zK5W}V`XL1L34`+HNS%}4=?A32E`dvV)KqeEY6sXZ>Vqr%EE0xRf!BZU9ZAttUYe%t zgkbfqRv0zgMV{yd%gyf<$w!?Ut?2D@1}+vNjau2#7xEYVK^DQ!5HRtFHK4*gitv^E z1#Ua1W>jIkyJnQEV5C9}c6;ReG=28)MD$e)3o)v3+F^v!Cqt zDzc(77<+iJhE*>RgQ7J1v4w$bP1`!6Gs?11MFcDuyuK)l*Lggj6}~e_D=XY~&~E;w z44^Sr?4=qtyZWghc7ujf56O!=QUTBXndj$d2MdJe@)9_WOJec&H;zDQwux<%gr_`) zPzKd@W{`RhFpdQo9c2Mc?QcF-U+s6gtd-VXrLacicLEPJq*>hcj)2}%blg@X$#%FD zUICd(!PO#sw7~d92JStT+y;C1pDmnVc9nQr#o0DS4D2V= zXx9W4Odfm3@#7Mz4)7ZmOqa|6fN+K}r^B5j?PVRm_`AXGVjA%0R*SrtsZQ|*;PCtW z#?~i$zHkY?(yQ`QLcD|7{>^1ywS|imf^y{{#=c3izb46k;_Z;|j_e4p|DE?Y#hZS9 z#2?>H1(C@72uy6ogs*Y$e3-3i&wDyZZNyNJ>(xqPtUUQ{XQ}mpU`tRX!L2Hv6PdQVZ>?%;#GY$H;7z@ z8g7u8$|{L~KY4SFizk(Xm@F7ADWhPb)js&7jvpYr@iV4N_wyng zK6(kb@UR2%qIzW8SLQQ_r+l@SiJsgJ`QIE=59>|DZgZkPV2AG!Q(hX0S4W9gFXbPGI7l^BuY(FLFC&8gf`Qkt!> z^z9_FVBD=^^+^oq`A|83(g!5@@$+wu&e%$|QdnN#4?cR-w{G33k|U|O)_3W@edsGI zN*pc&d(e}Lxx($%zj^s>CY!hkTe1?smtgOCi7v`oip&U?BWVRkw{reD9$(JF9!BmvUX5lH)@jYF(S6OrJ~|Pr*O^ zeFFC1kjf5x`b9n{Smkjpa&J6~^_n#7*2qB~AUH3O;jn+ma8a&FtV<3d89kwXXRo~I zl^E9IpG+3tt#Y82=J!#-aAJ>rg66@0vQi$lg4W8)mKcksc@LgzLI3y#ww{>mX^ev0 z!;38zGj@9HXiQlft}!1>T`8X@EG}z0prqOLFqxRL>*orDzokRCKWB&gey<>VjG8PNdT@*&tE zwXuM@u&{Ap5)Ah2mJG<{qI5~G;IrH-(EF_ifOf_~0o2;5Ae?#(9`^8-<5QiG#bHBJ zM9A+k&>OwZNdie{d6rbE{T~L{YdKK+23#e=EC(TwYWSoE`N;uH{YFk96cSbQU>=Ej z@}tc;D%7ImY6U!T4D;)WL}N`S=B4t30hE)_^nvCjKKnOGKNP<$rl>bFs#T-Wj%dbf zP>t2?!z*E%JZ$M7P=hF?aMAdYBhAvQqz6FRJH&jWq~W3Z`z6gYsggM~X*c8tZzj?W zo30j$IezZ7{3)A}U+sYt9nh-;jVoFTK-V;>LB2iWb2N#))TC<+BBFAs12Ux$X zA2h1npJ&~S<=wjDc+^viZXj2szr@G%V>EALm}D8)caDy!>~O;)o$e^02R(|_<@YW| zuGznhV%)@cLITdDE;1(I&LJp>6F1e1PZlpp5ia0HiR;H6Lv}qok0=->?1w5--eYExcMZskHQkP5bCa;-Z%c+=@IPmJ z>WO$#&Jv>;6ufEBKz zo|KAtMJU`}>iMQb*h4u_B{`xEe9fLX!$|8o}IpgvCFZ z_yMmm-RA{^COh&0&vrnt15^?kyN1R~G+p}3KNjV>e#j%?3iK~nbUVXW6Ip9Pxzwj)**KVU5! z_ks>~C+wONg7jttq+|~P3#Xc^(~H=-9x>0 zGKDhcF}*mT`*$-_tTeIFJCCWTgfqaXpP_-sYUbDTZ@QW-5_Him){$A@tB~`(GtMof zMLE=U;pJNnho{qOrM2J2J6KA@ouTzRB33`x2q-!NZ~Moa#LfnORfDsIYTCCpMYhy> zxk{~t7WQeo@~=fU_PJaEn$YmVU#|k2ve^5*S#`L&1a|N>so~UlNn-4!>88|b-KXTl zjiJ>ePdG=^X+USekwIXaeoHIwJ5kh}te+BNo1-dKt|y4qn4i?Qesi2_T@=*b5I{N4J^MZ<6(U40bTCn7YM*GR@JCijLcLErCK<^&m5P1h8BD6kZV^<7#|(tvZ0F zo#N6%8mKc#(TRGiteHX%T8cTFR~g1ZKSYjAgH+}%C6 zYjAgWcXv&2Cj^39kPx`dIrn}cxifQSzFWOkul@Fqdb-wI+iKUYM*um@pA?Vvl{bW* zuaawx+jA_omW?AzXyZeHEGi4dVhgFmnR>NQHHEk6d7v@|^&ji&zX40W0oN{N)!%$q zbTd3M04-lV-67v|FkOw_Je+@Es(IIb*~tJVaT#v2V{Vs>@liMmE@uUUrgCC&asmVl(@4_pofzV4U@CJLC6aMF8j>k161X^?GoTP zM(BHzV-1?#&&)~=$RR_r`>AIP^=MS^3jh+wXJ!0NcvJf-Hy_GdUlOF%G@WuRZ`+2; z;cQRCA^;#L_nsGLs!31Ri2AoEO=k?h(ifH(#>S@Cf2F#YW*&{mzc02GtRgLem$dax z-r^8uP9lg^Bf}ony(?}Ta2_-%ig}5`R>bXTuy7=y}har z(b5yz-IMm&b!@6BDxyZsm0URkw1?Ay+x%4cqN)refrsTQda=uy8{p=nJ{d#St3%$Q z#{Al(Yi=LCyAazd55m zBJR|7WfChT)!2{$6^Wd9V4E;dU3uhkWg!a?N+DM>JV|*4e{Ly?z#JpGmqg1`lh!g* z0SIs&PpgsslqQP&eEz71HQ)?JZXqnGP`MIU@*tBm-$@OpWt|u_R~tHv1XW0=`t?R< z^RegvT{#AF1LByrNAO`i>Lu&4$o*~A1p15I>jiug1V|eUX*8AWOZWjHdpPL_s*Jrn zu@H@|Oir;o-%7W=6HOYIVdjy0F|NIlj*TdWEvg9~Uh)dU5X_2D5Y$akOn9IWIFjH@ z*x1eqA8xJ|Y=~|Y5dx9o-trFcg4;tK6&475;yx`oblDVukBf2SiKSr*&F>J^7 zPe|}luvUj`#zC2BPJNMPCtXCnQrEdi8JTV_A-vPv0E_%>xyd*0Q*aCi3hj$Riy~7f z(lrf%&afh6Lcpg)FaP7k0vyQN;;F;C9zZSoN7 zNpzW;sTQ#DAeQ9jYcOdfV4~D?^%@?&8Ms=`+AuRN;9hXMo=(F(+LXv^ElC+Fwb^ha z;h5@Z?4D!6-`oLRDBWma6$;T}CIHQAZ;8b5PNnqvh?L{I9M9~!OICM;*w`>+nN2$Y zgp<|~A|`$+p=Z)N7{hI7;Gfqph&CP$cJK)|@CfrF>*RPo5LS`d!U*z4`93CB){lm;w#E!MTrqbi?|c*nQ;zi zZdYsAgA4$^J8&5ExU2o4&@xEzk=t5YCejjh>Aot_it zJlp9!s-qlP7D$>wvpBWr7Z!*ds%mM%@7{;CiP>V5NzkVa#ZvLIWf16Jb5X z6-i`D0ld4x&wSW*kcR+KV@ejiH1L}<5wG;?ZIX_-lMG0qyG>-OC>cw|l?p=~!rHPo|B-6D95%5dh z_q*Ea2G6Tlmgw?Iqw(>kMnD?)hx{)n7%rw4qhY0NnMarSo@W7Vn7OASWnnHQa6$TZ!{J}4chH+9nWr^Yz zCB&YqtciKGdCHjgqNesEnS~V6T3~>SzHwGnbR|&`lI&_o=^nnZ>^3xUEZW|0xcP>!@X6dW2hkHv zktxm8r20pA>_LY^%nx|14ZUgC$RMViufTa%6cyZK3|9T>7q2Th_H5^;I47W{K3C>+ zIIOaVwVcH`l0&Z3j`poYyp+T*(leD!9L^=nhwavBN85^p(lsuA*{c6Q5_`19fzck{ z@h~KIWmM4ZHE?hAYUPdOceqN4xc;Eo6)BR2Msa)YW_WX9H}f91{?m5%B|1*?#6pOf z8E5gOfwcBvh69X`lKS_1-^rsr(&WBXP!`|xY~JfO-fDIxld16eYQ{QgU~2}99)nps zmPCqrN2b5z+cE&Ff!6tk4n`CUHYas~t`iITDmvbTY zu9Yo5TRHE?@yWs};r!w*dHJ0Mw=EhKB|E7kBkk%b38JrrA`C=Styfz$v_W+Nu*cYz zpKIS+6N$WAy)*#T>)Eh{no+;%7kw~M5hH-^#vT7$#8s}ePeMr#VUb)38d!q+XmlTgI&z$aHA5FY- zfyWKy!p10$!`kGJwx#6}jFAu>5T!z%W)roA>&!v5u4uI^>46Ni;Zv}2NHT&)I!9KH zZ+f3fBGXMLli(8wlpPI_?Q`TP-!v7d3wO|U ziimN8k7T(vmrG})%eWbFSAYfH%WCyU$W?M2frzRy{WU{5%sbYdt1J|4O6BqQ(FZu) zMQlrR>Wx7Zs7{a~v>uV9bO42o@Ah*yySmE$1)^u6%<{Gmp?zUQO$DdV_r{;Og4wOh zapk3YBvp)R%g_f9?HGM?9o1oDA55UC&I>UwB32I}lBmwR4klHn!asrD6XQ>iSbmlf z)%e`A>lkMNbx|zv%_t4*QA`TT=QLgPv&Q{!fD0b0>BsN=r{+RI_f00~b|cGr#S1ey zAHP$*{qa4;xbE%#+S@!+&BKOe#0Evss#I?iLZb41HpxhO^R%b2k*DoQA&YvL z*OhaLvTHMZv}1FFYvph51Iq~lzMK|H-o8JBAwmTd78CZs-86Z^@_(z{QYDm2_m3k7 z+$p)DqMBdV+&RViGD{<#ar?B=Lk#*KD6f z(-!B2IpUJJFw(n;5m&8j1M7wmYMkCOi^ZjtM~EIbzHZ%6LgYGz>Y2Qyl?gbeHL{Z3 z^1>6^792lgzSOv`aUI@0OOzz*Sla$*C{IK%p#3RTO<%*~#CfLt<#IXlux9dBqDCI` zVSaMuG1$@Wn_hy>6Gp9QYkh;@p1P65M0c?Mlx+!d-rY@mt=^&(0iccXbMWXb;<8&*JqzWwg2tnou!mGLht7(;`HQExVzjR3uM^uE13VcQ+Egwp zSifr1y5gTwv`R;kXo*XvNV&%7t87-*~W|AYtB`tyB{ zjIO=$zf-kA=^c*`GFB#*_I9FH<_7xeWVTG=U?s;^ylC*G0oL6b7UeBl6Sx?bI z755%IB>sILCT!nuXt1PH!Qt#mlA$5yEzA$T+rHip6f8=UnXh^U&B9tCVUmrRPR zp!&XyJ4R!RhAoV?eOXTY!U6gmt#nwfJUhWh+z)~_r)#Ox7)!5{Qug4u3j2HmoG4SW zBQd0B!A(wR;O8JIKZLk{)}U6!O(2))u*j{yQ=HF~#Kp~P5&zuhdT`3aFK=pkLjXLR zqSAcQ;jYu(m!U?jVVkdZ3?pbZ!j4^0;S^NeGaV%AeHh@riLwgiU)$Ld+ zrbqKhHdTxoyE=79hsuDmA5h5~%#J#|7$pdY`t>fKH2v+nt-GLoA|`@oIR(xk9jme6 zBZ1}voC+1;>Z=TPt)?)w2u;8mPD9uFvqVfFecto}!^Im3ZoVKK`MzXNN!KtLMN4LS zo&+s?bmOEO5r7X%WP3yH#6FBeYmw-3(H!nwFBQWU&Rm0{&F-Q-b-igWqfL0Tm7>DX zyf!Ia2Z#B%zu5jfS(GbjQ3S+!R;D@og8TvSJXlqDy<}ZNx-VN_MLN&q(?NdG7$Go_ zS}oly87*DavN!)dG;DkW2bu6nvvPe%eQ{SvAjWETdMID@HhTh_3X8|unFBnW`K=Yk zrr&p`@X4|M;4>bPsbFkoSI#zQc+Z29NlVpav2Y-M23W7jp!MZpJD!RsnuS}3$oJr} zDZJffO0I1Odknojm6VNEl6O~T9+ZdmjtDCfcC3!-OGP5z!>Q*J7QBbGPN$TtNHXsXLTpRp9Vl^m%|N4&~w5kCtr>BMY{61pVXB*Qjb7?E{Ug zMi)dUx)V$VNL9~>HcVdYP}AzYpxuCU_tkX|*=h`9;tmM2@X+kTOfmw4nW6Dhv(ZNc0j-qqZbSenJ9C|2Pv& zG+3Cp*EpM?3LL|=_ONRDZJ8xa)Y|mZu4)o?sUHZn-DLuERQgV-4ZpLr0HaMk{2Zj?n{hW-n+-|qw6 zUk7-e4s`!cT1!({k^>Qe^5jsXn!T>H>mj}N<)^5ht4d>!Akd3ac}>hmS*h_#j3cx* z0N|G8s62n;olAA>BK8^UE=oZ)m>TKE`z}_Kmx&@K}=x1hx+zhz?YXu(VS#}6cL zvumnQ|4w_R6UleH@s63iO!H3Ng*Z^Tm#I$%0E@5C+>%7jinj@BMdstQ4_qU%plK z$V2+osPvu{piio@NucBF=NXQQ3kes%+O68z&$e9f4%WioYV{Vz*mVqaVZvOg)On&j z@mJuM6Na5j9Jz<(12u=p?E;IoID8Cj>H$$yFKEakNyo9BUmU}xmWr*ZTDYl8H|kn7 zMNmmh)~*B)hy$J2NtBS*1L)p#kGNDjSBl{%7+KBG_5C;HyMVsfGreArwHyY|PYuFu89 zjmA3gkiHQ^?Uo|Z;5>_LU!~^_qOUpG_r{Ku5894Yutz!RC$@)dh`w5)jt8}2`2W~s z>~Wnmd@VtBJjJi4jIJPDdfSecpc$V?LxG2u6T1v?Yp> z&K`H3pUMiicuT)sR^5JX?BpT>s+jGSzWcP0?l)uZfr}cg=CD-_o|x{j^z&oM;%TM0 z^9oUN5L@SE$@t1pM!2S_{F|FkxiN3<&+yLj&~I=Onr+jyPd78J?XMtrI16(Xj&jF& z!tv*z_J4RU3OMS=vbXocw|9}bj|AQX30+I;bCtMh?&;x?n1u3RaHn*k!bSD+@W2Hx zOGxISau?~{K!1(znf)9++rgcFz8XDVw|cP5_cjA)&yR>Sfp&XGj|`XzkLnE3Qm)EB zJ{y>*h*&mz4DOB&_bX?EYB@jqrSJb<;rFj=^xN6Hm;>n~t&FUGhI!>5fBWx&K*>TL zO&Q&j)@7lxJUdIav=GGv;aRY_RGp0aGfI=^?Z}n2pPES}s+$k;Qy{AxZ}aW|Mi8l- zGoA;MULi9`heU-Q7JIU@cvjlW=56ZTc4jh&) zw%NXvse0xdxJAU8LA=AKR=y}+m7@Cc_H*hkvmi$Qw~l#UUK0+bkMO1UY_BN<2^JN1 z>5?t`t`5Y@zouL!Yg%qowcpHZY>vdz&=*fq7920eXXae;*|%!6(G2(d25(x28R3wM2YPUkX#9 zC#2QkdRc(@-U;3wkVk*5bOm>Zf~P68RA;EHobbAX6c2VNTbVTta;5>eA}8#ZEOkYP z^&kQz;vi`Kwsl`I$N&nHIaQ9mnN5{bnwZ9D;$#Y5^T5E^2m#+aYlG+gZ2H~a?f?u& z5`#5zAOsMh7a2=006ukq{#>C*ySRdF40C%B@;NT5FD=flc{URKu4oXJ%X6X-tl$6) zN#}?!W{t&@SCbk{b`g6@c90@p$C{3mm6cz!HCAcWfAu+Rf}u}q%S(<=P!nS_T`iUu zn^=9Fmsomn;9kXEx&_Od@G|L0A%C8g1M9k89U<0DrvR3GA6B>p3Yg?x11q#pXOKXo z=uuQVsr}y1?wKN!A`>5!s!@6)L$V|x?x*efq zJ%by$PA#v!dC~TD_Wdf7OD85JdU^Ln*YUyDdo{ERs z6`_o&YvpGNjTcc0%Og6WM;yBa2avshU2)3|(;CW-NA?(3za04#M*}%kBchH3H&;69 zyRUZdug{9BB+zqp(D1-ms#+i*~DJl+!Mwa(ZU|}J~Rl@eQe~i>SoQ*MB z{GBV5@_gq8zcU%f6YJ!RbOT$wm5IW=PFc_WjU(Arp8m+p6J}RyeCX)b^0z5P31c0| z2s~84hEvOLWYV<44K}W59=$(8mz=%OzEu0AU=Mz42LsZ&F~(ul6d6McS5HhOhFEMc zc6C~@U!y8x#u8cRAIur)LToP3Z-Gmnl#%A_z4|JYOhV}=-{?KlIQ)^_g(@?0Y>LR$ zt88yB;wmrU%9j_QP5`)#qd?8x7o0*4N9&{KUq-l0hT6i73lQrgqQVHiE5&KY-RYA4 z$~tbP%Si-;{z9Dc0fRDGYT2?#oOt5ZF|WH19h;4pXaedD=BNVm4)TtkG#QZ@9pUzt z?JmcWe3-(Snm9?L$`HAKp~CBU(LVV_%;IYN#qJzO;&VxWs5Jg_NwowB_?T3{Hv5%` z*beg)cQEx9G5$T%89K>zaD#un+nBSX@sf9-tLPF`yC2bXy$=-lH0D603W7;W|A#Qj zYKC`9sqdDl&!wa>5{d=PQe^;pW?6}mRfL$S=-<$|!yWJcMKIHd6Q?@?S~20kJlz`p zK5BCQ<<{`uqo$OJg9-Z|51R^Oa%lX>_pcTVVu3yHko6P z$`(x$?H2Xb?G=Q#;iR5ML25~BK1n}mCKgrJ&?~fgN9Cb2>sx<;0~`Nf(lQu@z zRzAnk&)PJz6=%<(Ty9;k*Hmp%$L%?pfUx$}ukQ`tyAUj;@Or&=!W42=yXs++7;RK_VY<_Y4JTQxA(b6Q>EVyh2^-9hYD)&)>HvN?=08I13C_JU*L=xvlS>^3oa%%<%gt)|oYYBrbl z>mV;M4e%)zBI5MF!+}=JjaxgcwlLMk=_aJ~5S8LCGVR2haJueVmkuvpy2QdLQZ!5uIygg6&%+;OOpU$Rt?^v}igas`1I-QGniL>Vv) z>LPzm8^HZHx(Dd)qo|dg{U3J_B^P;lW%LKlSmYqAJ_@R8ifn9Re>o0-`X-BbjOB-f0|!)c^vf~Vj3Y`WbVn!;Ny8m6YJWxENU zk>l5qYY(@3SYU_Oyg>-y12H@1=ATrUnPa^e?6evVa)E}73`IP%>PWupSeOY+5Wf#&!udEJMwt=8N^<7XGoKhS%mzru(p@QaHOocO#VR=0n2!ne zII2B+m?uKX!jN}uhT>N|LHOpUJcFF)0BG~rd4F*q9fPQQ731@$=#L^hhU5;^SyI&x z8vv#vpfJnGddDUxbfloxROTq_t% z+;^T_MwR&t8CPa3<^?I~aFS_EkX}w_{TcoH{^;7{_vyB{|tMJEgK9B8;jArAs80hS{BCTM?#IK3a zHiLixxGXx6#WvU+@U5+cxICItLmFMwV;KXTvLP)Ru8M^;YOM2sc4WM~_xR8o9Plp~ z1iyaLkO*6U4Z3zrFxT5yJk!NEyy_ARM?t2;bQ{+rj*5z<+8mcV5EjQYCZ~_O^%|ox z?4q_ORRVQ_Y2eET4zH`Tn>TNrVU&FvC6h9ycIs4wNAmT1l9_0-bYn7X(VB1+668@i z%`%T{@$C{e-bqN>Jxf5nE5;M}!2c?!WY%>*6TkpBEJLY3h(S52MXFjXF*lS2ZUw~= zoXCHu4ayDRP@XBjWMv<}qK*V22YFiz&bDMT%BNMwSm)<<@v zBFwrngkY_?38jTXk-=D|8ved17!sGn>~mY8?#}2o0O*Rt z8U1E)emQlvd!}d~4@8jbr_|)^(n-PBVpQvp#h=P#h8eLkIzJKDiARhv5O37co>a=; z#}5$T&h)5_r}!)pT1C%j^+CH+o#OCf6_TYYA1_4ghfs?Zg`n@_Cf{W>au|_aKJyz> zz#_^jJD}*|`jD4!IA&a5w#4uLL-ZLpA~r@#G914<_2iv4ugQh1b9a45diKTC7Zz_= z!hS);lMk|PNeweWvRvUhWR%W}Giv+=(hg9=IU6(^)_wUQJQDM_&ly;CxXJ*FJ_HxF zAxe|w{#CK#G9gqvLRTB)ik(fnsE$Hj!ejnkFb*!WXe;zp{y$upg^#oDMFh{-2Nez( z)vT~4k8C3`CNav!bI8?V2lkBfGOW&b)i24!SH1haGY%AW@QP(ae-sMfIm%_1oACREfa9|rl@SkoiyMS* znzi+Jur1zAh+ehB+MRU~e2VM5j;zX%8VkxM`*tjCq35?28YnSn2z0bkH;b z2AVEFzdp8}ijB!Oe@9F=& zhS={2PwN{z7OVLwx|V+r;g`}izpHJOp!Mqr z2A}a)n*T%S|EU3wFi!&mkI}K8@;3KB!~A)y?01}}3(m(d!%xvI_|G_h6?Bl1fCMeh Sz`*c8pAOJ+)~4+7>AwITby79} literal 0 HcmV?d00001 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..c52c29642 --- /dev/null +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java @@ -0,0 +1,28 @@ +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) { + 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..6755745e0 --- /dev/null +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -0,0 +1,204 @@ +package tonlabs.uikit; + +import com.facebook.react.bridge.ReactContext; +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.graphics.Canvas; +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; + +/** + * https://github.com/Mixiaoxiao/OverScroll-Everywhere + * + * @author Mixiaoxiao 2016-08-31 + */ +public class ReactOverScrollView extends ReactScrollView implements OverScrollable { + + private OverScrollDelegate mOverScrollDelegate; + + private boolean mDragging; + + // =========================================================== + // Constructors + // =========================================================== + public ReactOverScrollView(ReactContext context) { + super(context); + createOverScrollDelegate(context); + } + + // =========================================================== + // createOverScrollDelegate + // =========================================================== + private void createOverScrollDelegate(Context context) { + mOverScrollDelegate = new OverScrollDelegate(this); + } + + // =========================================================== + // Delegate + // =========================================================== + @Override + public boolean onInterceptTouchEvent(MotionEvent ev) { + final int action = MotionEventCompat.getActionMasked(ev); + if (action == MotionEvent.ACTION_DOWN) { + NativeGestureUtil.notifyNativeGestureStarted(this, ev); + // TODO: it's fired twice for some reason + ReactScrollViewHelper.emitScrollBeginDragEvent(this); + mDragging = true; + } + if (mOverScrollDelegate.onInterceptTouchEvent(ev)) { + return true; + } + return super.onInterceptTouchEvent(ev); + } + + + + @Override + public boolean onTouchEvent(MotionEvent event) { + // TODO: mScrollEnabled from ReactScrollView + final int action = MotionEventCompat.getActionMasked(event); + if (action == MotionEvent.ACTION_UP && mDragging) { + // TODO: velocity + // TODO: it's fired twice for some reason + ReactScrollViewHelper.emitScrollEndDragEvent(this, 0, 0); + mDragging = false; +// return true; + } + int offset = this.superComputeVerticalScrollOffset(); + int range = this.superComputeVerticalScrollRange() - this.superComputeVerticalScrollExtent(); + Log.d("ReactOverScrollView", String.format("range: %d, offset: %d, canScrollUp: %b, canScrollDown: %b", range, offset, offset > 0, offset < range - 1)); + if (mOverScrollDelegate.onTouchEvent(event)) { + 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); + float mOffsetY = (float) mOffsetYField.get(mOverScrollDelegate); + + if (mState == OverScrollDelegate.OS_DRAG_TOP || mState == OverScrollDelegate.OS_DRAG_BOTTOM) { + ReactContext reactContext = (ReactContext) this.getContext(); + int surfaceId = UIManagerHelper.getSurfaceId(reactContext); + UIManagerHelper.getEventDispatcherForReactTag(reactContext, this.getId()).dispatchEvent(ScrollEvent.obtain(surfaceId, this.getId(), ScrollEventType.SCROLL, 0, (int) (-1 * mOffsetY), 0, 0, 0, 0, 0, 0)); + } + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + return true; + } + return super.onTouchEvent(event); + } + + @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, 0.0F); + invalidate(); + } + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); + } + super.scrollTo(x, y); + } +} \ 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/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index 7778624eb..6f0401cf4 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -58,7 +58,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 ( @@ -108,7 +113,12 @@ 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); + // 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 + scrollTo(scrollRef, 0, 1, false); } }; } diff --git a/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx b/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx index 07c1abfe4..7349a8986 100644 --- a/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx +++ b/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx @@ -20,14 +20,8 @@ export function wrapScrollableComponent( const { onLayout, onContentSizeChange } = useHasScroll(); - const { - ref, - panGestureHandlerRef, - scrollHandler, - gestureHandler, - registerScrollable, - unregisterScrollable, - } = React.useContext(ScrollableContext); + const { ref, scrollHandler, gestureHandler, registerScrollable, unregisterScrollable } = + React.useContext(ScrollableContext); React.useEffect(() => { if (registerScrollable) { @@ -47,31 +41,15 @@ export function wrapScrollableComponent( }); return ( - - - - {/* @ts-ignore */} - - - - + ); } From ddd24822ecff8bcc4a7c1e414425c19fd7f69005 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Fri, 19 Nov 2021 12:08:04 +0300 Subject: [PATCH 14/20] Add momentum events invoking --- .../java/tonlabs/uikit/ReactOverScrollView.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java index 6755745e0..1262b1eec 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -22,6 +22,8 @@ import com.mixiaoxiao.overscroll.PathScroller; import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; /** * https://github.com/Mixiaoxiao/OverScroll-Everywhere @@ -78,6 +80,20 @@ public boolean onTouchEvent(MotionEvent event) { // TODO: it's fired twice for some reason ReactScrollViewHelper.emitScrollEndDragEvent(this, 0, 0); mDragging = false; + + // TODO: create a wrapper with method invoker + try { + Method handlePostTouchScrolling = getClass().getSuperclass().getDeclaredMethod("handlePostTouchScrolling", int.class, int.class); + handlePostTouchScrolling.setAccessible(true); + // TODO: velocity + handlePostTouchScrolling.invoke(this, 0, 0); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (InvocationTargetException e) { + e.printStackTrace(); + } catch (NoSuchMethodException e) { + e.printStackTrace(); + } // return true; } int offset = this.superComputeVerticalScrollOffset(); From 1adb9cef12cd1be73682ada6c404f34804458909 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Fri, 19 Nov 2021 19:19:34 +0300 Subject: [PATCH 15/20] Better integration with ReactScrollView --- .../tonlabs/uikit/ReactOverScrollView.java | 208 +++++++++++++----- 1 file changed, 149 insertions(+), 59 deletions(-) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java index 1262b1eec..d92580417 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -24,6 +24,7 @@ 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 @@ -34,8 +35,6 @@ public class ReactOverScrollView extends ReactScrollView implements OverScrollab private OverScrollDelegate mOverScrollDelegate; - private boolean mDragging; - // =========================================================== // Constructors // =========================================================== @@ -56,76 +55,167 @@ private void createOverScrollDelegate(Context context) { // =========================================================== @Override public boolean onInterceptTouchEvent(MotionEvent ev) { + if (!getScrollEnabled()) { + return false; + } + final int action = MotionEventCompat.getActionMasked(ev); if (action == MotionEvent.ACTION_DOWN) { - NativeGestureUtil.notifyNativeGestureStarted(this, ev); - // TODO: it's fired twice for some reason - ReactScrollViewHelper.emitScrollBeginDragEvent(this); - mDragging = true; + setParentDragging(true); } - if (mOverScrollDelegate.onInterceptTouchEvent(ev)) { - return 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 super.onInterceptTouchEvent(ev); + 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; - @Override - public boolean onTouchEvent(MotionEvent event) { - // TODO: mScrollEnabled from ReactScrollView - final int action = MotionEventCompat.getActionMasked(event); - if (action == MotionEvent.ACTION_UP && mDragging) { - // TODO: velocity - // TODO: it's fired twice for some reason - ReactScrollViewHelper.emitScrollEndDragEvent(this, 0, 0); - mDragging = false; - - // TODO: create a wrapper with method invoker - try { - Method handlePostTouchScrolling = getClass().getSuperclass().getDeclaredMethod("handlePostTouchScrolling", int.class, int.class); - handlePostTouchScrolling.setAccessible(true); - // TODO: velocity - handlePostTouchScrolling.invoke(this, 0, 0); - } catch (IllegalAccessException e) { - e.printStackTrace(); - } catch (InvocationTargetException e) { - e.printStackTrace(); - } catch (NoSuchMethodException e) { - e.printStackTrace(); + boolean getScrollEnabled() { + try { + // TODO: create a wrapper with getter method + if (mScrollEnabledField == null) { + mScrollEnabledField = getClass().getSuperclass().getDeclaredField("mScrollEnabled"); //NoSuchFieldException + mScrollEnabledField.setAccessible(true); } -// return true; + return (boolean) mScrollEnabledField.get(this); + } catch (IllegalAccessException e) { + e.printStackTrace(); + } catch (NoSuchFieldException e) { + e.printStackTrace(); + } catch (NullPointerException e) { + e.printStackTrace(); } - int offset = this.superComputeVerticalScrollOffset(); - int range = this.superComputeVerticalScrollRange() - this.superComputeVerticalScrollExtent(); - Log.d("ReactOverScrollView", String.format("range: %d, offset: %d, canScrollUp: %b, canScrollDown: %b", range, offset, offset > 0, offset < range - 1)); - if (mOverScrollDelegate.onTouchEvent(event)) { - 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); - float mOffsetY = (float) mOffsetYField.get(mOverScrollDelegate); - - if (mState == OverScrollDelegate.OS_DRAG_TOP || mState == OverScrollDelegate.OS_DRAG_BOTTOM) { - ReactContext reactContext = (ReactContext) this.getContext(); - int surfaceId = UIManagerHelper.getSurfaceId(reactContext); - UIManagerHelper.getEventDispatcherForReactTag(reactContext, this.getId()).dispatchEvent(ScrollEvent.obtain(surfaceId, this.getId(), ScrollEventType.SCROLL, 0, (int) (-1 * mOffsetY), 0, 0, 0, 0, 0, 0)); - } - } 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); + UIManagerHelper + .getEventDispatcherForReactTag(reactContext, this.getId()) + .dispatchEvent( + ScrollEvent.obtain( + surfaceId, + this.getId(), + ScrollEventType.SCROLL, + 0, + (int) (-1 * getOverScrollOffsetY()), + 0, + 0, + 0, + 0, + 0, + 0)); } return true; } - return super.onTouchEvent(event); + + 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 From 1de52ff0a44a1fb89e7f6c66f662d3b7dba452be Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 23 Nov 2021 11:44:44 +0300 Subject: [PATCH 16/20] Make it much smoother on Android --- .../tonlabs/uikit/ReactOverScrollView.java | 6 +- Example/src/App.tsx | 13 +- Example/src/screens/LargeHeader.tsx | 10 +- casts/bars/src/UILargeTitleHeader/index.tsx | 68 ++++-- .../useScrollHandler/useOnEndDrag.tsx | 4 + .../useScrollHandler/useOnMomentumEnd.tsx | 2 + .../useOnScrollHandler/onScroll.native.tsx | 216 +++++++++++++++++- .../wrapScrollableComponent.android.tsx | 5 +- 8 files changed, 277 insertions(+), 47 deletions(-) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java index d92580417..7b9858177 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -1,12 +1,14 @@ 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.graphics.Canvas; +import android.os.Build; import android.util.DisplayMetrics; import android.util.Log; import android.view.MotionEvent; @@ -146,7 +148,7 @@ public boolean onTouchEvent(MotionEvent ev) { this.getId(), ScrollEventType.SCROLL, 0, - (int) (-1 * getOverScrollOffsetY()), + (int) (-1 * getOverScrollOffsetY() * ev.getYPrecision()), 0, 0, 0, @@ -295,7 +297,7 @@ public void scrollTo(int x, int y) { if (mState == OverScrollDelegate.OS_DRAG_TOP || mState == OverScrollDelegate.OS_DRAG_BOTTOM) { mStateField.set(mOverScrollDelegate, OverScrollDelegate.OS_NONE); - mOffsetYField.set(mOverScrollDelegate, 0.0F); + mOffsetYField.set(mOverScrollDelegate, y); invalidate(); } } catch (IllegalAccessException e) { diff --git a/Example/src/App.tsx b/Example/src/App.tsx index eeccbfb94..35c86a0d7 100644 --- a/Example/src/App.tsx +++ b/Example/src/App.tsx @@ -146,6 +146,12 @@ const Main = ({ navigation }: { navigation: any }) => { paddingBottom: Math.max(bottom, tabBarBottomInset), }} > + navigation.navigate('large-header')} + layout={styles.button} + /> { type={UILinkButtonType.Menu} onPress={() => navigation.navigate('keyboard')} layout={styles.button} - /> - navigation.navigate('large-header')} - layout={styles.button} /> */} + )} - {new Array(9) + {new Array(19) .fill(null) .map((_el, i) => (i + 1) / 10) .map(opacity => ( @@ -121,10 +121,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..c116a097b 100644 --- a/casts/bars/src/UILargeTitleHeader/index.tsx +++ b/casts/bars/src/UILargeTitleHeader/index.tsx @@ -9,12 +9,15 @@ import Animated, { interpolate, useAnimatedReaction, scrollTo, + useDerivedValue, } from 'react-native-reanimated'; import { TouchableOpacity } from '@tonlabs/uikit.controls'; import { UIBackgroundView, UILabel, UILabelColors, UILabelRoles } from '@tonlabs/uikit.themes'; 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; } }, @@ -147,14 +150,26 @@ export function UILargeTitleHeader({ 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: - largeTitleHeight.value > 0 - ? Math.max(shift.value, -largeTitleHeight.value) - : shift.value, + translateY: translateY.value, + }, + ], + }; + }); + const scrollableStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateY: translateY.value + largeTitleHeight.value, }, ], }; @@ -168,7 +183,7 @@ export function UILargeTitleHeader({ transform: [ { scale: interpolate( - shift.value, + translateY.value, [0, RUBBER_BAND_EFFECT_DISTANCE], [1, LARGE_TITLE_SCALE], { @@ -178,7 +193,7 @@ export function UILargeTitleHeader({ }, { translateX: interpolate( - shift.value, + translateY.value, [0, RUBBER_BAND_EFFECT_DISTANCE], [0, (titleWidth.value * LARGE_TITLE_SCALE - titleWidth.value) / 2], { @@ -343,8 +358,12 @@ export function UILargeTitleHeader({ return ( - - + + {renderAboveContent && renderAboveContent()} @@ -367,19 +386,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} + + + + { 'worklet'; + console.log('onBeginEnd', event.contentOffset.y, event.velocity?.y); + + return; + if (event && parentScrollHandlerActive) { if (ctx.yWithoutRubberBand > 0) { parentScrollHandler(event); diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx index 002a1b625..cf18ce699 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx @@ -12,6 +12,8 @@ function withNormalizedMomentumEnd( return (event: NativeScrollEvent, ctx: ScrollHandlerContext) => { 'worklet'; + return; + /** * If we got there then there was an end event * while scroll view was in motion diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index 6f0401cf4..f47d414f9 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -10,8 +10,213 @@ import type { ScrollHandlerContext } from '../../types'; const isIOS = Platform.OS === 'ios'; -// eslint-disable-next-line func-names +/** + * 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 ~(y + headerHeight) + 1; + } + // 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 ( + scrollRef: React.RefObject, + largeTitleViewRef: React.RefObject, + largeTitleHeight: Animated.SharedValue, + yIsNegative: Animated.SharedValue, + currentPosition: Animated.SharedValue, + rubberBandDistance: number, + 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; + } + + /** + * 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 (isIOS && ctx != null && !ctx.scrollTouchGuard) { + 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('skipped2'); + 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) { + currentPosition.value = nextPosition; + 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 + */ + scrollTo(scrollRef, 0, 1, false); + // Compensate 1 described above + currentPosition.value = nextPosition + diff + 1; + return; + } + scrollTo(scrollRef, 0, 0, false); + currentPosition.value = nextPosition + diff; + }; +} + +// eslint-disable-next-line func-names +function _old( scrollRef: React.RefObject, largeTitleViewRef: React.RefObject, largeTitleHeight: Animated.SharedValue, @@ -85,7 +290,8 @@ export default function ( } if (y !== 0) { if (ctx != null) { - ctx.lastApproximateVelocity = y; + ctx.lastApproximateVelocity = y - ctx.lastKnownContentOffsetY; + ctx.lastKnownContentOffsetY = y; } } else { /** @@ -113,11 +319,7 @@ 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); - // 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 + scrollTo(scrollRef, 0, 1, false); } }; diff --git a/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx b/kit/scrolls/src/wrapScrollableComponent/wrapScrollableComponent.android.tsx index 7349a8986..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,11 +15,9 @@ export function wrapScrollableComponent( props: Props & { children?: React.ReactNode }, forwardRef: React.RefObject, ) { - const nativeGestureRef = React.useRef(null); - const { onLayout, onContentSizeChange } = useHasScroll(); - const { ref, scrollHandler, gestureHandler, registerScrollable, unregisterScrollable } = + const { ref, scrollHandler, registerScrollable, unregisterScrollable } = React.useContext(ScrollableContext); React.useEffect(() => { From 408a2ee907b4650d79e0b9a5be56da3cfb0799ca Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Tue, 23 Nov 2021 19:00:42 +0300 Subject: [PATCH 17/20] Working with velocity --- casts/bars/src/UILargeTitleHeader/index.tsx | 19 +- casts/bars/src/UILargeTitleHeader/types.ts | 8 - .../useScrollHandler/index.ts | 54 ++- .../useScrollHandler/scrollContext.ts | 88 ++++ .../useScrollHandler/useOnBeginDrag.tsx | 19 +- .../useScrollHandler/useOnEndDrag.tsx | 450 ++++++++++++------ .../useScrollHandler/useOnMomentumEnd.tsx | 73 ++- .../useOnScrollHandler/index.tsx | 8 +- .../useOnScrollHandler/onScroll.native.tsx | 19 +- .../useScrollFallbackGestureHandler/index.ts | 1 - ...seScrollFallbackGestureHandler.android.tsx | 109 ----- .../useScrollFallbackGestureHandler.tsx | 24 - 12 files changed, 492 insertions(+), 380 deletions(-) create mode 100644 casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts delete mode 100644 casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/index.ts delete mode 100644 casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.android.tsx delete mode 100644 casts/bars/src/UILargeTitleHeader/useScrollHandler/useScrollFallbackGestureHandler/useScrollFallbackGestureHandler.tsx diff --git a/casts/bars/src/UILargeTitleHeader/index.tsx b/casts/bars/src/UILargeTitleHeader/index.tsx index c116a097b..f1583a564 100644 --- a/casts/bars/src/UILargeTitleHeader/index.tsx +++ b/casts/bars/src/UILargeTitleHeader/index.tsx @@ -8,8 +8,8 @@ import Animated, { Extrapolate, interpolate, useAnimatedReaction, - scrollTo, useDerivedValue, + scrollTo, } from 'react-native-reanimated'; import { TouchableOpacity } from '@tonlabs/uikit.controls'; import { UIBackgroundView, UILabel, UILabelColors, UILabelRoles } from '@tonlabs/uikit.themes'; @@ -140,14 +140,13 @@ 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 translateY = useDerivedValue(() => { @@ -262,7 +261,8 @@ export function UILargeTitleHeader({ ref: scrollRef, panGestureHandlerRef, scrollHandler, - gestureHandler, + // TODO: remove + gestureHandler: null, onWheel, hasScroll, setHasScroll, @@ -273,7 +273,6 @@ export function UILargeTitleHeader({ scrollRef, panGestureHandlerRef, scrollHandler, - gestureHandler, onWheel, hasScroll, setHasScroll, @@ -336,15 +335,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( diff --git a/casts/bars/src/UILargeTitleHeader/types.ts b/casts/bars/src/UILargeTitleHeader/types.ts index 57d0477a9..e69de29bb 100644 --- a/casts/bars/src/UILargeTitleHeader/types.ts +++ b/casts/bars/src/UILargeTitleHeader/types.ts @@ -1,8 +0,0 @@ -export type ScrollHandlerContext = { - scrollTouchGuard: boolean; - continueResetOnMomentumEnd: boolean; - lastApproximateVelocity: number; - lastEndTimestamp: number; - lastMomentumTimestamp: number; - yWithoutRubberBand: number; -}; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts index d25f88dc0..66549db7c 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts @@ -9,69 +9,81 @@ import type { ScrollHandlerContext } from '../types'; import { useOnScrollHandler } from './useOnScrollHandler'; import { useOnWheelHandler } from './useOnWheelHandler'; import { useOnEndDrag } from './useOnEndDrag'; -import { useScrollFallbackGestureHandler } from './useScrollFallbackGestureHandler'; import { useOnMomentumEnd } from './useOnMomentumEnd'; import { useOnBeginDrag } from './useOnBeginDrag'; export function useScrollHandler( scrollRef: React.RefObject, 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 + ~(state.currentPosition + state.largeTitleHeight) + 1, + 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..bcd422fd0 --- /dev/null +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts @@ -0,0 +1,88 @@ +// @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 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 fde63b6fb..4efc2895b 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx @@ -5,10 +5,12 @@ import Animated, { withSpring, withDecay } 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,119 +31,281 @@ function normalizedEnd( return (event: NativeScrollEvent, ctx: ScrollHandlerContext) => { 'worklet'; - console.log('onBeginEnd', event.contentOffset.y, event.velocity?.y); + 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), + ); - return; + const { velocityY } = ctx; - if (event && parentScrollHandlerActive) { - if (ctx.yWithoutRubberBand > 0) { - parentScrollHandler(event); - return; - } - } + /** + * 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) { + /** + * TODO: maybe handle it outside to make it easier to read? + */ /** - * First of all handle upward motion - * This is when a header possibly could be shown + * 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 (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, { + 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; } + /** - * 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. + * TODO: this is not entirely true + * as we do `scrollTo(1)` + * to bottom motion, though coY can be ~1 + * need sth smarter than that */ - 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, - ); + /** + * 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 (event.contentOffset.y > 1.1) { + setFlingReal(ctx); + } + + /** + * 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 + */ + 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); 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(); }; }, }; @@ -149,7 +313,6 @@ function normalizedEnd( export function useOnEndDrag( shift: Animated.SharedValue, - scrollInProgress: Animated.SharedValue, largeTitleHeight: Animated.SharedValue, defaultShift: Animated.SharedValue, mightApplyShiftToScrollView: Animated.SharedValue, @@ -163,7 +326,8 @@ export function useOnEndDrag( if (onEndHandlerRef.current == null) { onEndHandlerRef.current = normalizedEnd( shift, - scrollInProgress, + largeTitleHeight, + mightApplyShiftToScrollView, parentScrollHandler, parentScrollHandlerActive, ).with({ @@ -180,22 +344,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. @@ -215,26 +381,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 @@ -245,10 +413,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 cf18ce699..13caf5042 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnMomentumEnd.tsx @@ -2,13 +2,10 @@ 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'; @@ -26,7 +23,6 @@ function withNormalizedMomentumEnd( * ScrollView already did all the necessary work. */ if (event.contentOffset.y > 0) { - scrollInProgress.value = false; return; } /** @@ -66,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< @@ -76,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 f47d414f9..4742bfd1d 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -6,7 +6,8 @@ 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 } from '../scrollContext'; +import type { ScrollHandlerContext } from '../scrollContext'; const isIOS = Platform.OS === 'ios'; @@ -107,13 +108,11 @@ function test() { assert(getNextPosition({ contentOffset: { y: 5 } } as any, -10, 50), -15); } -export default function ( +export default function createOnScroll( scrollRef: React.RefObject, largeTitleViewRef: React.RefObject, largeTitleHeight: Animated.SharedValue, - yIsNegative: Animated.SharedValue, currentPosition: Animated.SharedValue, - rubberBandDistance: number, parentScrollHandler: ScrollableParentScrollHandler, parentScrollHandlerActive: boolean, ) { @@ -129,6 +128,8 @@ export default function ( } /** + * TODO: rephrase it + * * The fix is needed only for iOS * * On iOS `onScroll` event could fire on mount sometimes, @@ -136,7 +137,7 @@ export default function ( * To prevent changes when there wasn't onBeginDrag event * (so it's likely not an actual scroll) using a guard */ - if (isIOS && ctx != null && !ctx.scrollTouchGuard) { + if (!(isDragging(ctx) || isFlingReal(ctx))) { return; } @@ -156,12 +157,11 @@ export default function ( if (y === 0) { // TODO: this is very important! - console.log('skipped2'); + console.log('skipped'); return; } const nextPosition = getNextPosition(event, currentPosition.value, largeTitleHeight.value); - const diff = nextPosition - currentPosition.value; // console.log(currentPosition.value, nextPosition, diff, y); @@ -171,6 +171,7 @@ export default function ( // regular scroll if (currentPosition.value < collapsedEdge && nextPosition < collapsedEdge) { currentPosition.value = nextPosition; + trackVelocity(diff, ctx as any); return; } @@ -208,10 +209,12 @@ export default function ( scrollTo(scrollRef, 0, 1, false); // Compensate 1 described above currentPosition.value = nextPosition + diff + 1; + trackVelocity(diff, ctx as any); return; } - scrollTo(scrollRef, 0, 0, false); currentPosition.value = nextPosition + diff; + scrollTo(scrollRef, 0, 0, false); + trackVelocity(diff, ctx as any); }; } 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; -} From 5b7667668f4509b5dd278769d8472178d95414fe Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Wed, 24 Nov 2021 13:28:05 +0300 Subject: [PATCH 18/20] Make it smoother --- .../tonlabs/uikit/ReactOverScrollView.java | 1 + .../useScrollHandler/index.ts | 2 +- .../useScrollHandler/scrollContext.ts | 18 ++++++++++++++++++ .../useScrollHandler/useOnEndDrag.tsx | 16 ++++++++++++---- .../useOnScrollHandler/onScroll.native.tsx | 18 ++++++++++++++---- 5 files changed, 46 insertions(+), 9 deletions(-) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java index 7b9858177..93fa65302 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -50,6 +50,7 @@ public ReactOverScrollView(ReactContext context) { // =========================================================== private void createOverScrollDelegate(Context context) { mOverScrollDelegate = new OverScrollDelegate(this); + mOverScrollDelegate.setOverScrollType(true, false); } // =========================================================== diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts index 66549db7c..0e1334a23 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts @@ -5,7 +5,7 @@ import Animated, { scrollTo, } from 'react-native-reanimated'; import { useScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; -import type { ScrollHandlerContext } from '../types'; +import type { ScrollHandlerContext } from './scrollContext'; import { useOnScrollHandler } from './useOnScrollHandler'; import { useOnWheelHandler } from './useOnWheelHandler'; import { useOnEndDrag } from './useOnEndDrag'; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts index bcd422fd0..bd71b02e1 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/scrollContext.ts @@ -63,6 +63,24 @@ export function setFlingReal(ctx: Contex 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'; diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx index 4efc2895b..fe4dc5dde 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnEndDrag.tsx @@ -1,11 +1,16 @@ /* 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'; +import { + isDragging, + setFlingEmulated, + setFlingReal, + setNoScroll, +} from './scrollContext'; function normalizedEnd( currentPosition: Animated.SharedValue, @@ -104,8 +109,10 @@ function normalizedEnd( /** * TODO: remove 1.1 ASAP!!!! */ - if (event.contentOffset.y > 1.1) { + if (currentPosition.value < -largeTitleHeight.value) { setFlingReal(ctx); + console.log('fling real'); + return; } /** @@ -154,6 +161,7 @@ function normalizedEnd( if (isUpMotion) { if (currentPosition.value < -largeTitleHeight.value) { setFlingReal(ctx); + console.log('fling real'); return; } // Nothing to do, regular scroll diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index 4742bfd1d..335acd23a 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -6,7 +6,13 @@ import type { ScrollView as RNScrollView, NativeScrollEvent } from 'react-native import { getYWithRubberBandEffect } from '@tonlabs/uikit.popups'; import type { ScrollableParentScrollHandler } from '@tonlabs/uikit.scrolls'; -import { trackVelocity, isDragging, isFlingReal } from '../scrollContext'; +import { + trackVelocity, + isDragging, + isFlingReal, + getStateDescription, + isFlingEmulated, +} from '../scrollContext'; import type { ScrollHandlerContext } from '../scrollContext'; const isIOS = Platform.OS === 'ios'; @@ -137,9 +143,10 @@ export default function createOnScroll( * 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))) { - return; - } + // if (!(isDragging(ctx) || isFlingReal(ctx))) { + // console.log('weird onScroll', getStateDescription(ctx)); + // return; + // } if (largeTitleHeight.value === 0) { try { @@ -170,6 +177,9 @@ export default function createOnScroll( // regular scroll if (currentPosition.value < collapsedEdge && nextPosition < collapsedEdge) { + if (isFlingEmulated(ctx)) { + return; + } currentPosition.value = nextPosition; trackVelocity(diff, ctx as any); return; From ff1f677d1f7de8c204c2e9465dcfd4f01550d5d1 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Thu, 25 Nov 2021 16:20:29 +0300 Subject: [PATCH 19/20] More experiments --- .../tonlabs/uikit/ReactOverScrollView.java | 9 ++++ Example/src/screens/LargeHeader.tsx | 28 +++++++++-- casts/bars/src/UILargeTitleHeader/index.tsx | 17 ++++++- .../useScrollHandler/index.ts | 2 +- .../useOnScrollHandler/onScroll.native.tsx | 49 +++++++++++++++++-- 5 files changed, 93 insertions(+), 12 deletions(-) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java index 93fa65302..3c0f2ba90 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollView.java @@ -7,6 +7,7 @@ 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; @@ -50,6 +51,7 @@ public ReactOverScrollView(ReactContext context) { // =========================================================== private void createOverScrollDelegate(Context context) { mOverScrollDelegate = new OverScrollDelegate(this); + mOverScrollDelegate.setOverScrollStyle(new OverScrollStyle()); mOverScrollDelegate.setOverScrollType(true, false); } @@ -141,6 +143,7 @@ public boolean onTouchEvent(MotionEvent ev) { 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( @@ -310,4 +313,10 @@ public void scrollTo(int x, int y) { } 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/src/screens/LargeHeader.tsx b/Example/src/screens/LargeHeader.tsx index 8daf728b5..a57f903eb 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'; @@ -30,15 +30,33 @@ function LargeHeaderExample() { )} {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', @@ -124,7 +142,7 @@ export function LargeHeaderScreen() { // renderAboveContent: () => { // return ; // }, - renderBelowContent, + // renderBelowContent, }} component={LargeHeaderExample} /> diff --git a/casts/bars/src/UILargeTitleHeader/index.tsx b/casts/bars/src/UILargeTitleHeader/index.tsx index f1583a564..e79544e37 100644 --- a/casts/bars/src/UILargeTitleHeader/index.tsx +++ b/casts/bars/src/UILargeTitleHeader/index.tsx @@ -165,6 +165,9 @@ export function UILargeTitleHeader({ }; }); const scrollableStyle = useAnimatedStyle(() => { + return { + marginTop: translateY.value + largeTitleHeight.value, + }; return { transform: [ { @@ -357,11 +360,21 @@ export function UILargeTitleHeader({ return ( - + {renderAboveContent && renderAboveContent()} diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts index 0e1334a23..1409652f6 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/index.ts @@ -67,7 +67,7 @@ export function useScrollHandler( scrollRef, 0, // eslint-disable-next-line no-bitwise - ~(state.currentPosition + state.largeTitleHeight) + 1, + -1 * (state.currentPosition + state.largeTitleHeight), false, ); } diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index 335acd23a..d674d60a7 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -76,7 +76,7 @@ function getNextPosition(event: NativeScrollEvent, currentPosition: number, head // (it doesn't save much, but anyway) // https://jsbench.me/8ikwaptgyt/1 // eslint-disable-next-line no-bitwise - return ~(y + headerHeight) + 1; + return -1 * (y + headerHeight); } // to bottom, rubber band effect // TODO: see above about determenism of scrollTo @@ -168,6 +168,13 @@ export default function createOnScroll( 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; @@ -216,18 +223,52 @@ export default function createOnScroll( * 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 */ - scrollTo(scrollRef, 0, 1, false); // Compensate 1 described above - currentPosition.value = nextPosition + diff + 1; + // currentPosition.value = nextPosition + diff + 1; + // ctx.skipOnNextScroll = true; + // scrollTo(scrollRef, 0, -diff, false); + currentPosition.value = nextPosition; + trackVelocity(diff, ctx as any); return; } currentPosition.value = nextPosition + diff; - scrollTo(scrollRef, 0, 0, false); + // currentPosition.value = nextPosition; + // scrollTo(scrollRef, 0, 0, false); trackVelocity(diff, ctx as any); }; } +/** + * --------------------------- + * | | | + * | | ----- | ----- + * ----- | ----- | | + * | | ----- | + * | ----- | | ----- + * ----- | | | + * + * + * | | + * | | ----- | + * ----- | ----- | | + * | | | + * | ----- | ----- | + * ----- | | | + * + * + * + * + * + * + * + * + * + * + * + * ----------------------------- + */ + // eslint-disable-next-line func-names function _old( scrollRef: React.RefObject, From 4f6ccdf96412e0b772c7c98bfe154f6cfd8577e8 Mon Sep 17 00:00:00 2001 From: Aleksei Saveliev Date: Thu, 25 Nov 2021 18:23:29 +0300 Subject: [PATCH 20/20] Disable OverScrollView --- .../main/java/tonlabs/uikit/ReactOverScrollPackage.java | 7 ++++--- Example/src/screens/LargeHeader.tsx | 2 +- .../useOnScrollHandler/onScroll.native.tsx | 5 +++-- casts/splitNavigator/package.json | 6 +++--- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java index c52c29642..596807309 100644 --- a/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java +++ b/Example/android/app/src/main/java/tonlabs/uikit/ReactOverScrollPackage.java @@ -21,8 +21,9 @@ public List createNativeModules(@Nonnull ReactApplicationContext r @Nonnull @Override public List createViewManagers(@Nonnull ReactApplicationContext reactContext) { - List modules = new ArrayList<>(); - modules.add(new ReactOverScrollViewManager()); - return modules; + return Collections.emptyList(); +// List modules = new ArrayList<>(); +// modules.add(new ReactOverScrollViewManager()); +// return modules; } } \ No newline at end of file diff --git a/Example/src/screens/LargeHeader.tsx b/Example/src/screens/LargeHeader.tsx index a57f903eb..3cd07a5fa 100644 --- a/Example/src/screens/LargeHeader.tsx +++ b/Example/src/screens/LargeHeader.tsx @@ -142,7 +142,7 @@ export function LargeHeaderScreen() { // renderAboveContent: () => { // return ; // }, - // renderBelowContent, + renderBelowContent, }} component={LargeHeaderExample} /> diff --git a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx index d674d60a7..a397a80a3 100644 --- a/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx +++ b/casts/bars/src/UILargeTitleHeader/useScrollHandler/useOnScrollHandler/onScroll.native.tsx @@ -224,10 +224,11 @@ export default function createOnScroll( * overscroll animation, but in reality we don't want it here */ // Compensate 1 described above - // currentPosition.value = nextPosition + diff + 1; + currentPosition.value = nextPosition + diff; // ctx.skipOnNextScroll = true; // scrollTo(scrollRef, 0, -diff, false); - currentPosition.value = nextPosition; + // currentPosition.value = nextPosition; + scrollTo(scrollRef, 0, 0, false); trackVelocity(diff, ctx as any); return; diff --git a/casts/splitNavigator/package.json b/casts/splitNavigator/package.json index 879020f2f..ca5025f34 100644 --- a/casts/splitNavigator/package.json +++ b/casts/splitNavigator/package.json @@ -34,9 +34,9 @@ "src/" ], "dependencies": { - "@tonlabs/uikit.themes": "^2.3.0", - "@tonlabs/uikit.controls": "^2.3.0", - "@tonlabs/uikit.media": "^2.3.0", + "@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"