diff --git a/packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx b/packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx new file mode 100644 index 00000000..da312972 --- /dev/null +++ b/packages/stack/src/TransitionConfigs/CardStyleInterpolators.tsx @@ -0,0 +1,140 @@ +import Animated from 'react-native-reanimated'; +import { CardInterpolationProps, CardInterpolatedStyle } from '../types'; + +const { cond, multiply, interpolate } = Animated; + +/** + * Standard iOS-style slide in from the right. + */ +export function forHorizontalIOS({ + progress: { current, next }, + layouts: { screen }, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateFocused = interpolate(current, { + inputRange: [0, 1], + outputRange: [screen.width, 0], + }); + const translateUnfocused = next + ? interpolate(next, { + inputRange: [0, 1], + outputRange: [0, multiply(screen.width, -0.3)], + }) + : 0; + + const opacity = interpolate(current, { + inputRange: [0, 1], + outputRange: [0, 0.07], + }); + + const shadowOpacity = interpolate(current, { + inputRange: [0, 1], + outputRange: [0, 0.3], + }); + + return { + cardStyle: { + backgroundColor: '#eee', + transform: [ + // Translation for the animation of the current card + { translateX: translateFocused }, + // Translation for the animation of the card on top of this + { translateX: translateUnfocused }, + ], + shadowOpacity, + }, + overlayStyle: { opacity }, + }; +} + +/** + * Standard iOS-style slide in from the bottom (used for modals). + */ +export function forVerticalIOS({ + progress: { current }, + layouts: { screen }, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [screen.height, 0], + }); + + return { + cardStyle: { + backgroundColor: '#eee', + transform: [ + // Translation for the animation of the current card + { translateY }, + ], + }, + }; +} + +/** + * Standard Android-style fade in from the bottom for Android Oreo. + */ +export function forFadeFromBottomAndroid({ + progress: { current }, + layouts: { screen }, + closing, +}: CardInterpolationProps): CardInterpolatedStyle { + const translateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [multiply(screen.height, 0.08), 0], + }); + + const opacity = cond( + closing, + current, + interpolate(current, { + inputRange: [0, 0.5, 0.9, 1], + outputRange: [0, 0.25, 0.7, 1], + }) + ); + + return { + cardStyle: { + opacity, + transform: [{ translateY }], + }, + }; +} + +/** + * Standard Android-style wipe from the bottom for Android Pie. + */ +export function forWipeFromBottomAndroid({ + progress: { current, next }, + layouts: { screen }, +}: CardInterpolationProps): CardInterpolatedStyle { + const containerTranslateY = interpolate(current, { + inputRange: [0, 1], + outputRange: [screen.height, 0], + }); + const cardTranslateYFocused = interpolate(current, { + inputRange: [0, 1], + outputRange: [multiply(screen.height, 95.9 / 100, -1), 0], + }); + const cardTranslateYUnfocused = next + ? interpolate(next, { + inputRange: [0, 1], + outputRange: [0, multiply(screen.height, 2 / 100, -1)], + }) + : 0; + const overlayOpacity = interpolate(current, { + inputRange: [0, 0.36, 1], + outputRange: [0, 0.1, 0.1], + }); + + return { + containerStyle: { + transform: [{ translateY: containerTranslateY }], + }, + cardStyle: { + transform: [ + { translateY: cardTranslateYFocused }, + { translateY: cardTranslateYUnfocused }, + ], + }, + overlayStyle: { opacity: overlayOpacity }, + }; +} diff --git a/packages/stack/src/TransitionConfigs/HeaderStyleInterpolators.tsx b/packages/stack/src/TransitionConfigs/HeaderStyleInterpolators.tsx new file mode 100644 index 00000000..4768ba66 --- /dev/null +++ b/packages/stack/src/TransitionConfigs/HeaderStyleInterpolators.tsx @@ -0,0 +1,100 @@ +import Animated from 'react-native-reanimated'; +import { HeaderInterpolationProps, HeaderInterpolatedStyle } from '../types'; + +const { interpolate, add } = Animated; + +export function forUIKit({ + progress: { current, next }, + layouts, +}: HeaderInterpolationProps): HeaderInterpolatedStyle { + const defaultOffset = 100; + const leftSpacing = 27; + + // The title and back button title should cross-fade to each other + // When screen is fully open, the title should be in center, and back title should be on left + // When screen is closing, the previous title will animate to back title's position + // And back title will animate to title's position + // We achieve this by calculating the offsets needed to translate title to back title's position and vice-versa + const leftLabelOffset = layouts.leftLabel + ? (layouts.screen.width - layouts.leftLabel.width) / 2 - leftSpacing + : defaultOffset; + const titleLeftOffset = layouts.title + ? (layouts.screen.width - layouts.title.width) / 2 - leftSpacing + : defaultOffset; + + // When the current title is animating to right, it is centered in the right half of screen in middle of transition + // The back title also animates in from this position + const rightOffset = layouts.screen.width / 4; + + const progress = add(current, next ? next : 0); + + return { + leftButtonStyle: { + opacity: interpolate(progress, { + inputRange: [0.3, 1, 1.5], + outputRange: [0, 1, 0], + }), + }, + leftLabelStyle: { + transform: [ + { + translateX: interpolate(progress, { + inputRange: [0, 1, 2], + outputRange: [leftLabelOffset, 0, -rightOffset], + }), + }, + ], + }, + rightButtonStyle: { + opacity: interpolate(progress, { + inputRange: [0.3, 1, 1.5], + outputRange: [0, 1, 0], + }), + }, + titleStyle: { + opacity: interpolate(progress, { + inputRange: [0, 0.4, 1, 1.5], + outputRange: [0, 0.1, 1, 0], + }), + transform: [ + { + translateX: interpolate(progress, { + inputRange: [0.5, 1, 2], + outputRange: [rightOffset, 0, -titleLeftOffset], + }), + }, + ], + }, + backgroundStyle: { + transform: [ + { + translateX: interpolate(progress, { + inputRange: [0, 1, 2], + outputRange: [layouts.screen.width, 0, -layouts.screen.width], + }), + }, + ], + }, + }; +} + +export function forFade({ + progress: { current, next }, +}: HeaderInterpolationProps): HeaderInterpolatedStyle { + const progress = add(current, next ? next : 0); + const opacity = interpolate(progress, { + inputRange: [0, 1, 2], + outputRange: [0, 1, 0], + }); + + return { + leftButtonStyle: { opacity }, + rightButtonStyle: { opacity }, + titleStyle: { opacity }, + backgroundStyle: { opacity }, + }; +} + +export function forNoAnimation(): HeaderInterpolatedStyle { + return {}; +} diff --git a/packages/stack/src/TransitionConfigs/TransitionPresets.tsx b/packages/stack/src/TransitionConfigs/TransitionPresets.tsx new file mode 100644 index 00000000..9b82c2ca --- /dev/null +++ b/packages/stack/src/TransitionConfigs/TransitionPresets.tsx @@ -0,0 +1,69 @@ +import { + forHorizontalIOS, + forVerticalIOS, + forWipeFromBottomAndroid, + forFadeFromBottomAndroid, +} from './CardStyleInterpolators'; +import { forUIKit, forNoAnimation } from './HeaderStyleInterpolators'; +import { + TransitionIOSSpec, + WipeFromBottomAndroidSpec, + FadeOutToBottomAndroidSpec, + FadeInFromBottomAndroidSpec, +} from './TransitionSpecs'; +import { TransitionPreset } from '../types'; +import { Platform } from 'react-native'; + +const ANDROID_VERSION_PIE = 28; + +// Standard iOS navigation transition +export const SlideFromRightIOS: TransitionPreset = { + direction: 'horizontal', + transitionSpec: { + open: TransitionIOSSpec, + close: TransitionIOSSpec, + }, + cardStyleInterpolator: forHorizontalIOS, + headerStyleInterpolator: forUIKit, +}; + +// Standard iOS navigation transition for modals +export const ModalSlideFromBottomIOS: TransitionPreset = { + direction: 'vertical', + transitionSpec: { + open: TransitionIOSSpec, + close: TransitionIOSSpec, + }, + cardStyleInterpolator: forVerticalIOS, + headerStyleInterpolator: forNoAnimation, +}; + +// Standard Android navigation transition when opening or closing an Activity on Android < 9 +export const FadeFromBottomAndroid: TransitionPreset = { + direction: 'vertical', + transitionSpec: { + open: FadeInFromBottomAndroidSpec, + close: FadeOutToBottomAndroidSpec, + }, + cardStyleInterpolator: forFadeFromBottomAndroid, + headerStyleInterpolator: forNoAnimation, +}; + +// Standard Android navigation transition when opening or closing an Activity on Android >= 9 +export const WipeFromBottomAndroid: TransitionPreset = { + direction: 'vertical', + transitionSpec: { + open: WipeFromBottomAndroidSpec, + close: WipeFromBottomAndroidSpec, + }, + cardStyleInterpolator: forWipeFromBottomAndroid, + headerStyleInterpolator: forNoAnimation, +}; + +export const DefaultTransition = Platform.select({ + ios: SlideFromRightIOS, + default: + Platform.OS === 'android' && Platform.Version < ANDROID_VERSION_PIE + ? FadeFromBottomAndroid + : WipeFromBottomAndroid, +}); diff --git a/packages/stack/src/TransitionConfigs/TransitionSpecs.tsx b/packages/stack/src/TransitionConfigs/TransitionSpecs.tsx new file mode 100644 index 00000000..426b541c --- /dev/null +++ b/packages/stack/src/TransitionConfigs/TransitionSpecs.tsx @@ -0,0 +1,43 @@ +import { Easing } from 'react-native-reanimated'; +import { TransitionSpec } from '../types'; + +export const TransitionIOSSpec: TransitionSpec = { + timing: 'spring', + config: { + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, + }, +}; + +// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_open_enter.xml +export const FadeInFromBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 350, + easing: Easing.out(Easing.poly(5)), + }, +}; + +// See http://androidxref.com/7.1.1_r6/xref/frameworks/base/core/res/res/anim/activity_close_exit.xml +export const FadeOutToBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 150, + easing: Easing.in(Easing.linear), + }, +}; + +// See http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/res/res/anim/activity_open_enter.xml +export const WipeFromBottomAndroidSpec: TransitionSpec = { + timing: 'timing', + config: { + duration: 425, + // This is super rough approximation of the path used for the curve by android + // See http://androidxref.com/9.0.0_r3/xref/frameworks/base/core/res/res/interpolator/fast_out_extra_slow_in.xml + easing: Easing.bezier(0.35, 0.45, 0, 1), + }, +}; diff --git a/packages/stack/src/index.tsx b/packages/stack/src/index.tsx new file mode 100644 index 00000000..dc56284e --- /dev/null +++ b/packages/stack/src/index.tsx @@ -0,0 +1,33 @@ +import * as CardStyleInterpolators from './TransitionConfigs/CardStyleInterpolators'; +import * as HeaderStyleInterpolators from './TransitionConfigs/HeaderStyleInterpolators'; +import * as TransitionPresets from './TransitionConfigs/TransitionPresets'; + +/** + * Navigators + */ +export { + default as createStackNavigator, +} from './navigators/createStackNavigator'; + +export const Assets = [ + require('./views/assets/back-icon.png'), + require('./views/assets/back-icon-mask.png'), +]; + +/** + * Views + */ +export { default as Header } from './views/Header/Header'; +export { default as HeaderTitle } from './views/Header/HeaderTitle'; +export { default as HeaderBackButton } from './views/Header/HeaderBackButton'; + +/** + * Transition presets + */ +export { CardStyleInterpolators, HeaderStyleInterpolators, TransitionPresets }; + +/** + * Utilities + */ + +export { default as StackGestureContext } from './utils/StackGestureContext'; diff --git a/packages/stack/src/navigators/createStackNavigator.tsx b/packages/stack/src/navigators/createStackNavigator.tsx new file mode 100644 index 00000000..9d598c33 --- /dev/null +++ b/packages/stack/src/navigators/createStackNavigator.tsx @@ -0,0 +1,33 @@ +import { StackRouter, createNavigator } from '@react-navigation/core'; +import { createKeyboardAwareNavigator } from '@react-navigation/native'; +import { Platform } from 'react-native'; +import StackView from '../views/Stack/StackView'; +import { NavigationStackOptions, NavigationProp, Screen } from '../types'; + +function createStackNavigator( + routeConfigMap: { + [key: string]: + | Screen + | ({ screen: Screen } | { getScreen(): Screen }) & { + path?: string; + navigationOptions?: + | NavigationStackOptions + | ((options: { + navigation: NavigationProp; + }) => NavigationStackOptions); + }; + }, + stackConfig: NavigationStackOptions = {} +) { + const router = StackRouter(routeConfigMap, stackConfig); + + // Create a navigator with StackView as the view + let Navigator = createNavigator(StackView, router, stackConfig); + if (!stackConfig.disableKeyboardHandling && Platform.OS !== 'web') { + Navigator = createKeyboardAwareNavigator(Navigator, stackConfig); + } + + return Navigator; +} + +export default createStackNavigator; diff --git a/packages/stack/src/types.tsx b/packages/stack/src/types.tsx new file mode 100644 index 00000000..94fa8def --- /dev/null +++ b/packages/stack/src/types.tsx @@ -0,0 +1,210 @@ +import { + StyleProp, + TextStyle, + ViewStyle, + LayoutChangeEvent, +} from 'react-native'; +import Animated from 'react-native-reanimated'; + +export type Route = { + key: string; + routeName: string; +}; + +export type NavigationEventName = + | 'willFocus' + | 'didFocus' + | 'willBlur' + | 'didBlur'; + +export type NavigationState = { + key: string; + index: number; + routes: Route[]; + transitions: { + pushing: string[]; + popping: string[]; + }; + params?: { [key: string]: unknown }; +}; + +export type NavigationProp = { + navigate(routeName: RouteName): void; + goBack(): void; + goBack(key: string | null): void; + addListener: ( + event: NavigationEventName, + callback: () => void + ) => { remove: () => void }; + isFocused(): boolean; + state: NavigationState; + setParams(params: Params): void; + getParam(): Params; + dispatch(action: { type: string }): void; + dangerouslyGetParent(): NavigationProp | undefined; +}; + +export type Layout = { width: number; height: number }; + +export type GestureDirection = 'horizontal' | 'vertical'; + +export type HeaderMode = 'float' | 'screen' | 'none'; + +export type HeaderScene = { + route: T; + descriptor: SceneDescriptor; + progress: { + current: Animated.Node; + next?: Animated.Node; + previous?: Animated.Node; + }; +}; + +export type HeaderOptions = { + headerTitle?: string; + headerTitleStyle?: StyleProp; + headerTintColor?: string; + headerTitleAllowFontScaling?: boolean; + headerBackAllowFontScaling?: boolean; + headerBackTitle?: string; + headerBackTitleStyle?: StyleProp; + headerTruncatedBackTitle?: string; + headerLeft?: (props: HeaderBackButtonProps) => React.ReactNode; + headerLeftContainerStyle?: StyleProp; + headerRight?: () => React.ReactNode; + headerRightContainerStyle?: StyleProp; + headerBackImage?: HeaderBackButtonProps['backImage']; + headerPressColorAndroid?: string; + headerBackground?: () => React.ReactNode; + headerStyle?: StyleProp; + headerStatusBarHeight?: number; +}; + +export type HeaderProps = { + mode: 'float' | 'screen'; + layout: Layout; + scene: HeaderScene; + previous?: HeaderScene; + navigation: NavigationProp; + styleInterpolator: HeaderStyleInterpolator; +}; + +export type NavigationStackOptions = HeaderOptions & { + title?: string; + header?: null | ((props: HeaderProps) => React.ReactNode); + gesturesEnabled?: boolean; + gestureResponseDistance?: { + vertical?: number; + horizontal?: number; + }; + disableKeyboardHandling?: boolean; +}; + +export type NavigationConfig = TransitionPreset & { + mode: 'card' | 'modal'; + headerMode: HeaderMode; + headerBackTitleVisible?: boolean; + transparentCard?: boolean; +}; + +export type SceneDescriptor = { + key: string; + options: NavigationStackOptions; + navigation: NavigationProp; + getComponent(): React.ComponentType; +}; + +export type HeaderBackButtonProps = { + disabled?: boolean; + onPress?: () => void; + pressColorAndroid?: string; + backImage?: (props: { tintColor: string; label?: string }) => React.ReactNode; + tintColor?: string; + label?: string; + truncatedLabel?: string; + labelVisible?: boolean; + labelStyle?: React.ComponentProps['style']; + allowFontScaling?: boolean; + onLabelLayout?: (e: LayoutChangeEvent) => void; + screenLayout?: Layout; + titleLayout?: Layout; +}; + +export type Screen = React.ComponentType & { + navigationOptions?: NavigationStackOptions & { + [key: string]: any; + }; +}; + +export type SpringConfig = { + damping: number; + mass: number; + stiffness: number; + restSpeedThreshold: number; + restDisplacementThreshold: number; + overshootClamping: boolean; +}; + +export type TimingConfig = { + duration: number; + easing: Animated.EasingFunction; +}; + +export type TransitionSpec = + | { timing: 'spring'; config: SpringConfig } + | { timing: 'timing'; config: TimingConfig }; + +export type CardInterpolationProps = { + progress: { + current: Animated.Node; + next?: Animated.Node; + }; + closing: Animated.Node<0 | 1>; + layouts: { + screen: Layout; + }; +}; + +export type CardInterpolatedStyle = { + containerStyle?: any; + cardStyle?: any; + overlayStyle?: any; +}; + +export type CardStyleInterpolator = ( + props: CardInterpolationProps +) => CardInterpolatedStyle; + +export type HeaderInterpolationProps = { + progress: { + current: Animated.Node; + next?: Animated.Node; + }; + layouts: { + screen: Layout; + title?: Layout; + leftLabel?: Layout; + }; +}; + +export type HeaderInterpolatedStyle = { + leftLabelStyle?: any; + leftButtonStyle?: any; + rightButtonStyle?: any; + titleStyle?: any; + backgroundStyle?: any; +}; + +export type HeaderStyleInterpolator = ( + props: HeaderInterpolationProps +) => HeaderInterpolatedStyle; + +export type TransitionPreset = { + direction: GestureDirection; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; + }; + cardStyleInterpolator: CardStyleInterpolator; + headerStyleInterpolator: HeaderStyleInterpolator; +}; diff --git a/packages/stack/src/utils/StackGestureContext.tsx b/packages/stack/src/utils/StackGestureContext.tsx new file mode 100644 index 00000000..61618d6a --- /dev/null +++ b/packages/stack/src/utils/StackGestureContext.tsx @@ -0,0 +1,6 @@ +import * as React from 'react'; +import { PanGestureHandler } from 'react-native-gesture-handler'; + +export default React.createContext | undefined>( + undefined +); diff --git a/packages/stack/src/utils/memoize.tsx b/packages/stack/src/utils/memoize.tsx new file mode 100644 index 00000000..6d5e22af --- /dev/null +++ b/packages/stack/src/utils/memoize.tsx @@ -0,0 +1,33 @@ +export default function memoize>( + callback: (...deps: Deps) => Result +) { + let previous: Deps | undefined; + let result: Result | undefined; + + return (...dependencies: Deps): Result => { + let hasChanged = false; + + if (previous) { + if (previous.length !== dependencies.length) { + hasChanged = true; + } else { + for (let i = 0; i < previous.length; i++) { + if (previous[i] !== dependencies[i]) { + hasChanged = true; + break; + } + } + } + } else { + hasChanged = true; + } + + previous = dependencies; + + if (hasChanged || result === undefined) { + result = callback(...dependencies); + } + + return result; + }; +} diff --git a/packages/stack/src/views/BorderlessButton.tsx b/packages/stack/src/views/BorderlessButton.tsx new file mode 100644 index 00000000..42f7a5f6 --- /dev/null +++ b/packages/stack/src/views/BorderlessButton.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { Animated, Platform } from 'react-native'; +import { BaseButton } from 'react-native-gesture-handler'; + +const AnimatedBaseButton = Animated.createAnimatedComponent(BaseButton); + +type Props = React.ComponentProps & { + activeOpacity: number; +}; + +export default class BorderlessButton extends React.Component { + static defaultProps = { + activeOpacity: 0.3, + borderless: true, + }; + + private opacity = new Animated.Value(1); + + private handleActiveStateChange = (active: boolean) => { + if (Platform.OS !== 'android') { + Animated.spring(this.opacity, { + stiffness: 1000, + damping: 500, + mass: 3, + overshootClamping: true, + restDisplacementThreshold: 0.01, + restSpeedThreshold: 0.01, + toValue: active ? this.props.activeOpacity : 1, + useNativeDriver: true, + }).start(); + } + + this.props.onActiveStateChange && this.props.onActiveStateChange(active); + }; + + render() { + const { children, style, enabled, ...rest } = this.props; + + return ( + + {children} + + ); + } +} diff --git a/packages/stack/src/views/Header/Header.tsx b/packages/stack/src/views/Header/Header.tsx new file mode 100644 index 00000000..cfe21eaa --- /dev/null +++ b/packages/stack/src/views/Header/Header.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; +import { StackActions } from '@react-navigation/core'; +import HeaderSegment from './HeaderSegment'; +import { HeaderProps } from '../../types'; + +export default class Header extends React.PureComponent { + render() { + const { + scene, + previous, + layout, + navigation, + styleInterpolator, + } = this.props; + const { options } = scene.descriptor; + const title = + options.headerTitle !== undefined ? options.headerTitle : options.title; + + let leftLabel; + + if (options.headerBackTitle !== undefined) { + leftLabel = options.headerBackTitle; + } else { + if (previous) { + const opts = previous.descriptor.options; + leftLabel = + opts.headerTitle !== undefined ? opts.headerTitle : opts.title; + } + } + + return ( + + navigation.dispatch(StackActions.pop({ key: scene.route.key })) + : undefined + } + styleInterpolator={styleInterpolator} + /> + ); + } +} diff --git a/packages/stack/src/views/Header/HeaderBackButton.tsx b/packages/stack/src/views/Header/HeaderBackButton.tsx new file mode 100644 index 00000000..81a05b1a --- /dev/null +++ b/packages/stack/src/views/Header/HeaderBackButton.tsx @@ -0,0 +1,246 @@ +import * as React from 'react'; +import { + I18nManager, + Image, + View, + Platform, + StyleSheet, + LayoutChangeEvent, + MaskedViewIOS, +} from 'react-native'; +import Animated from 'react-native-reanimated'; +import TouchableItem from '../TouchableItem'; +import { HeaderBackButtonProps } from '../../types'; + +type Props = HeaderBackButtonProps & { + tintColor: string; +}; + +type State = { + initialLabelWidth?: number; +}; + +class HeaderBackButton extends React.Component { + static defaultProps = { + pressColorAndroid: 'rgba(0, 0, 0, .32)', + tintColor: Platform.select({ + ios: '#037aff', + web: '#5f6368', + }), + labelVisible: Platform.OS === 'ios', + truncatedLabel: 'Back', + }; + + state: State = {}; + + private handleLabelLayout = (e: LayoutChangeEvent) => { + const { onLabelLayout } = this.props; + + onLabelLayout && onLabelLayout(e); + + if (this.state.initialLabelWidth) { + return; + } + + this.setState({ + initialLabelWidth: e.nativeEvent.layout.x + e.nativeEvent.layout.width, + }); + }; + + private renderBackImage() { + const { backImage, labelVisible, tintColor } = this.props; + + let label = this.getLabelText(); + + if (backImage) { + return backImage({ tintColor, label }); + } else { + return ( + + ); + } + } + + private getLabelText = () => { + const { titleLayout, screenLayout, label, truncatedLabel } = this.props; + + let { initialLabelWidth: initialLabelWidth } = this.state; + + if (!label) { + return truncatedLabel; + } else if ( + initialLabelWidth && + titleLayout && + screenLayout && + (screenLayout.width - titleLayout.width) / 2 < initialLabelWidth + 26 + ) { + return truncatedLabel; + } else { + return label; + } + }; + + private maybeRenderTitle() { + const { + allowFontScaling, + labelVisible, + backImage, + labelStyle, + tintColor, + screenLayout, + } = this.props; + + let leftLabelText = this.getLabelText(); + + if (!labelVisible || leftLabelText === undefined) { + return null; + } + + const title = ( + + {this.getLabelText()} + + ); + + if (backImage) { + return title; + } + + return ( + + + + + } + > + {title} + + ); + } + + private handlePress = () => + this.props.onPress && requestAnimationFrame(this.props.onPress); + + render() { + const { pressColorAndroid, label, disabled } = this.props; + + return ( + + + {this.renderBackImage()} + {this.maybeRenderTitle()} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + ...Platform.select({ + ios: null, + default: { + marginVertical: 3, + marginHorizontal: 11, + }, + }), + }, + disabled: { + opacity: 0.5, + }, + title: { + fontSize: 17, + // Title and back title are a bit different width due to title being bold + // Adjusting the letterSpacing makes them coincide better + letterSpacing: 0.35, + }, + icon: Platform.select({ + ios: { + height: 21, + width: 13, + marginLeft: 8, + marginRight: 22, + marginVertical: 12, + resizeMode: 'contain', + transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], + }, + default: { + height: 24, + width: 24, + margin: 3, + resizeMode: 'contain', + transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], + }, + }), + iconWithTitle: + Platform.OS === 'ios' + ? { + marginRight: 6, + } + : {}, + iconMaskContainer: { + flex: 1, + flexDirection: 'row', + justifyContent: 'center', + }, + iconMaskFillerRect: { + flex: 1, + backgroundColor: '#000', + }, + iconMask: { + height: 21, + width: 13, + marginLeft: -14.5, + marginVertical: 12, + alignSelf: 'center', + resizeMode: 'contain', + transform: [{ scaleX: I18nManager.isRTL ? -1 : 1 }], + }, +}); + +export default HeaderBackButton; diff --git a/packages/stack/src/views/Header/HeaderBackground.tsx b/packages/stack/src/views/Header/HeaderBackground.tsx new file mode 100644 index 00000000..05023000 --- /dev/null +++ b/packages/stack/src/views/Header/HeaderBackground.tsx @@ -0,0 +1,29 @@ +import * as React from 'react'; +import { View, StyleSheet, Platform, ViewProps } from 'react-native'; + +type Props = ViewProps; + +export default function HeaderBackground({ style, ...rest }: Props) { + return ; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + ...Platform.select({ + android: { + elevation: 4, + }, + ios: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#A7A7AA', + }, + default: { + // https://github.com/necolas/react-native-web/issues/44 + // Material Design + boxShadow: `0 2px 4px -1px rgba(0,0,0,0.2), 0 4px 5px 0 rgba(0,0,0,0.14), 0 1px 10px 0 rgba(0,0,0,0.12)`, + }, + }), + }, +}); diff --git a/packages/stack/src/views/Header/HeaderContainer.tsx b/packages/stack/src/views/Header/HeaderContainer.tsx new file mode 100644 index 00000000..5295f8eb --- /dev/null +++ b/packages/stack/src/views/Header/HeaderContainer.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { + View, + StyleSheet, + LayoutChangeEvent, + StyleProp, + ViewStyle, +} from 'react-native'; +import { getDefaultHeaderHeight } from './HeaderSegment'; +import { + Layout, + Route, + HeaderScene, + NavigationProp, + HeaderStyleInterpolator, +} from '../../types'; +import Header from './Header'; + +type Props = { + mode: 'float' | 'screen'; + layout: Layout; + scenes: HeaderScene[]; + navigation: NavigationProp; + onLayout?: (e: LayoutChangeEvent) => void; + styleInterpolator: HeaderStyleInterpolator; + style?: StyleProp; +}; + +export default function HeaderContainer({ + mode, + scenes, + layout, + navigation, + onLayout, + styleInterpolator, + style, +}: Props) { + const focusedRoute = navigation.state.routes[navigation.state.index]; + + return ( + + {scenes.map((scene, i, self) => { + if (mode === 'screen' && i !== self.length - 1) { + return null; + } + + const { options } = scene.descriptor; + const isFocused = focusedRoute.key === scene.route.key; + + const props = { + mode, + layout, + scene, + previous: self[i - 1], + navigation: scene.descriptor.navigation, + styleInterpolator, + }; + + return ( + + {options.header !== undefined ? ( + options.header == null ? null : ( + options.header(props) + ) + ) : ( +
+ )} + + ); + })} + + ); +} diff --git a/packages/stack/src/views/Header/HeaderSegment.tsx b/packages/stack/src/views/Header/HeaderSegment.tsx new file mode 100644 index 00000000..e83c56be --- /dev/null +++ b/packages/stack/src/views/Header/HeaderSegment.tsx @@ -0,0 +1,256 @@ +import * as React from 'react'; +import { View, StyleSheet, LayoutChangeEvent, Platform } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { getStatusBarHeight } from 'react-native-safe-area-view'; +import HeaderTitle from './HeaderTitle'; +import HeaderBackButton from './HeaderBackButton'; +import HeaderBackground from './HeaderBackground'; +import memoize from '../../utils/memoize'; +import { + Layout, + HeaderStyleInterpolator, + Route, + HeaderBackButtonProps, + HeaderOptions, + HeaderScene, +} from '../../types'; + +export type Scene = { + route: T; + progress: Animated.Node; +}; + +type Props = HeaderOptions & { + layout: Layout; + onGoBack?: () => void; + title?: string; + leftLabel?: string; + scene: HeaderScene; + styleInterpolator: HeaderStyleInterpolator; +}; + +type State = { + titleLayout?: Layout; + leftLabelLayout?: Layout; +}; + +export const getDefaultHeaderHeight = (layout: Layout) => { + const isLandscape = layout.width > layout.height; + + let headerHeight; + + if (Platform.OS === 'ios') { + // @ts-ignore + if (isLandscape && !Platform.isPad) { + headerHeight = 32; + } else { + headerHeight = 44; + } + } else if (Platform.OS === 'android') { + headerHeight = 56; + } else { + headerHeight = 64; + } + + return headerHeight + getStatusBarHeight(isLandscape); +}; + +export default class HeaderSegment extends React.Component { + static defaultProps = { + headerBackground: () => , + }; + + state: State = {}; + + private handleTitleLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + const { titleLayout } = this.state; + + if ( + titleLayout && + height === titleLayout.height && + width === titleLayout.width + ) { + return; + } + + this.setState({ titleLayout: { height, width } }); + }; + + private handleLeftLabelLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + const { leftLabelLayout } = this.state; + + if ( + leftLabelLayout && + height === leftLabelLayout.height && + width === leftLabelLayout.width + ) { + return; + } + + this.setState({ leftLabelLayout: { height, width } }); + }; + + private getInterpolatedStyle = memoize( + ( + styleInterpolator: HeaderStyleInterpolator, + layout: Layout, + current: Animated.Node, + next: Animated.Node | undefined, + titleLayout: Layout | undefined, + leftLabelLayout: Layout | undefined + ) => + styleInterpolator({ + progress: { + current, + next, + }, + layouts: { + screen: layout, + title: titleLayout, + leftLabel: leftLabelLayout, + }, + }) + ); + + render() { + const { + scene, + layout, + title: currentTitle, + leftLabel: previousTitle, + onGoBack, + headerLeft: left = (props: HeaderBackButtonProps) => ( + + ), + headerBackground, + headerStatusBarHeight, + headerRight: right, + headerBackImage: backImage, + headerBackTitle: leftLabel, + headerTruncatedBackTitle: truncatedLabel, + headerPressColorAndroid: pressColorAndroid, + headerBackAllowFontScaling: backAllowFontScaling, + headerTitleAllowFontScaling: titleAllowFontScaling, + headerTitleStyle: customTitleStyle, + headerBackTitleStyle: customLeftLabelStyle, + headerLeftContainerStyle: leftContainerStyle, + headerRightContainerStyle: rightContainerStyle, + styleInterpolator, + } = this.props; + + const { leftLabelLayout, titleLayout } = this.state; + + const { + titleStyle, + leftButtonStyle, + leftLabelStyle, + rightButtonStyle, + backgroundStyle, + } = this.getInterpolatedStyle( + styleInterpolator, + layout, + scene.progress.current, + scene.progress.next, + titleLayout, + previousTitle ? leftLabelLayout : undefined + ); + + return ( + + {headerBackground ? ( + + {headerBackground()} + + ) : null} + layout.height), + }} + /> + + {onGoBack ? ( + + {left({ + backImage, + pressColorAndroid, + allowFontScaling: backAllowFontScaling, + onPress: onGoBack, + label: leftLabel !== undefined ? leftLabel : previousTitle, + truncatedLabel, + labelStyle: [leftLabelStyle, customLeftLabelStyle], + onLabelLayout: this.handleLeftLabelLayout, + screenLayout: layout, + titleLayout, + })} + + ) : null} + {currentTitle ? ( + + {currentTitle} + + ) : null} + {right ? ( + + {right()} + + ) : null} + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + paddingHorizontal: 4, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + left: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'flex-start', + }, + right: { + position: 'absolute', + right: 0, + top: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'flex-end', + }, + title: Platform.select({ + ios: {}, + default: { position: 'absolute' }, + }), +}); diff --git a/packages/stack/src/views/Header/HeaderTitle.tsx b/packages/stack/src/views/Header/HeaderTitle.tsx new file mode 100644 index 00000000..826dcd14 --- /dev/null +++ b/packages/stack/src/views/Header/HeaderTitle.tsx @@ -0,0 +1,31 @@ +import * as React from 'react'; +import { StyleSheet, Platform } from 'react-native'; +import Animated from 'react-native-reanimated'; + +type Props = React.ComponentProps & { + children: string; +}; + +export default function HeaderTitle({ style, ...rest }: Props) { + return ; +} + +const styles = StyleSheet.create({ + title: Platform.select({ + ios: { + fontSize: 17, + fontWeight: '600', + color: 'rgba(0, 0, 0, .9)', + }, + android: { + fontSize: 20, + fontWeight: '500', + color: 'rgba(0, 0, 0, .9)', + }, + default: { + fontSize: 18, + fontWeight: '400', + color: '#3c4043', + }, + }), +}); diff --git a/packages/stack/src/views/Stack/Card.tsx b/packages/stack/src/views/Stack/Card.tsx new file mode 100755 index 00000000..c7dc771d --- /dev/null +++ b/packages/stack/src/views/Stack/Card.tsx @@ -0,0 +1,501 @@ +import * as React from 'react'; +import { View, StyleSheet, ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { + PanGestureHandler, + State as GestureState, +} from 'react-native-gesture-handler'; +import { TransitionSpec, CardStyleInterpolator, Layout } from '../../types'; +import memoize from '../../utils/memoize'; +import StackGestureContext from '../../utils/StackGestureContext'; + +type Props = ViewProps & { + closing?: boolean; + transparent?: boolean; + next?: Animated.Node; + current: Animated.Value; + layout: Layout; + direction: 'horizontal' | 'vertical'; + onOpen: () => void; + onClose: () => void; + onTransitionStart?: (props: { closing: boolean }) => void; + onGestureBegin?: () => void; + onGestureCanceled?: () => void; + onGestureEnd?: () => void; + children: React.ReactNode; + animateIn: boolean; + gesturesEnabled: boolean; + gestureResponseDistance?: { + vertical?: number; + horizontal?: number; + }; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; + }; + styleInterpolator: CardStyleInterpolator; +}; + +type Binary = 0 | 1; + +const TRUE = 1; +const FALSE = 0; +const NOOP = 0; +const UNSET = -1; + +const DIRECTION_VERTICAL = -1; +const DIRECTION_HORIZONTAL = 1; + +const SWIPE_VELOCITY_THRESHOLD_DEFAULT = 500; +const SWIPE_DISTANCE_THRESHOLD_DEFAULT = 60; + +const SWIPE_DISTANCE_MINIMUM = 5; + +/** + * The distance of touch start from the edge of the screen where the gesture will be recognized + */ +const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50; +const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135; + +const { + cond, + eq, + neq, + set, + and, + or, + greaterThan, + lessThan, + abs, + add, + max, + block, + stopClock, + startClock, + clockRunning, + onChange, + Value, + Clock, + call, + spring, + timing, + interpolate, +} = Animated; + +export default class Card extends React.Component { + static defaultProps = { + animateIn: true, + gesturesEnabled: true, + }; + + componentDidUpdate(prevProps: Props) { + const { layout, direction, closing, animateIn } = this.props; + const { width, height } = layout; + + if ( + width !== prevProps.layout.width || + height !== prevProps.layout.height + ) { + this.layout.width.setValue(width); + this.layout.height.setValue(height); + + this.position.setValue( + animateIn + ? direction === 'vertical' + ? layout.height + : layout.width + : 0 + ); + } + + if (direction !== prevProps.direction) { + this.direction.setValue( + direction === 'vertical' ? DIRECTION_VERTICAL : DIRECTION_HORIZONTAL + ); + } + + if (closing !== prevProps.closing) { + this.isClosing.setValue(closing ? TRUE : FALSE); + } + } + + private isVisible = new Value(TRUE); + private nextIsVisible = new Value(UNSET); + + private isClosing = new Value(FALSE); + + private clock = new Clock(); + + private direction = new Value( + this.props.direction === 'vertical' + ? DIRECTION_VERTICAL + : DIRECTION_HORIZONTAL + ); + + private layout = { + width: new Value(this.props.layout.width), + height: new Value(this.props.layout.height), + }; + + private distance = cond( + eq(this.direction, DIRECTION_VERTICAL), + this.layout.height, + this.layout.width + ); + + private position = new Value( + this.props.animateIn + ? this.props.direction === 'vertical' + ? this.props.layout.height + : this.props.layout.width + : 0 + ); + + private gesture = new Value(0); + private offset = new Value(0); + private velocity = new Value(0); + + private gestureState = new Value(0); + + private isSwiping = new Value(FALSE); + private isSwipeCancelled = new Value(FALSE); + private isSwipeGesture = new Value(FALSE); + + private toValue = new Value(0); + private frameTime = new Value(0); + + private transitionState = { + position: this.position, + time: new Value(0), + finished: new Value(FALSE), + }; + + private runTransition = (isVisible: Binary | Animated.Node) => { + const { open: openingSpec, close: closingSpec } = this.props.transitionSpec; + + const toValue = cond(isVisible, 0, this.distance); + + return cond(eq(this.position, toValue), NOOP, [ + cond(clockRunning(this.clock), NOOP, [ + // Animation wasn't running before + // Set the initial values and start the clock + set(this.toValue, toValue), + set(this.frameTime, 0), + set(this.transitionState.time, 0), + set(this.transitionState.finished, FALSE), + set(this.isVisible, isVisible), + startClock(this.clock), + call([this.isVisible], ([value]: ReadonlyArray) => { + const { onTransitionStart } = this.props; + + onTransitionStart && onTransitionStart({ closing: !value }); + }), + ]), + cond( + eq(toValue, 0), + openingSpec.timing === 'spring' + ? spring( + this.clock, + { ...this.transitionState, velocity: this.velocity }, + { ...openingSpec.config, toValue: this.toValue } + ) + : timing( + this.clock, + { ...this.transitionState, frameTime: this.frameTime }, + { ...openingSpec.config, toValue: this.toValue } + ), + closingSpec.timing === 'spring' + ? spring( + this.clock, + { ...this.transitionState, velocity: this.velocity }, + { ...closingSpec.config, toValue: this.toValue } + ) + : timing( + this.clock, + { ...this.transitionState, frameTime: this.frameTime }, + { ...closingSpec.config, toValue: this.toValue } + ) + ), + cond(this.transitionState.finished, [ + // Reset values + set(this.isSwipeGesture, FALSE), + set(this.gesture, 0), + set(this.velocity, 0), + // When the animation finishes, stop the clock + stopClock(this.clock), + call([this.isVisible], ([value]: ReadonlyArray) => { + const isOpen = Boolean(value); + const { onOpen, onClose } = this.props; + + if (isOpen) { + onOpen(); + } else { + onClose(); + } + }), + ]), + ]); + }; + + private translate = block([ + onChange( + this.isClosing, + cond(this.isClosing, set(this.nextIsVisible, FALSE)) + ), + onChange( + this.nextIsVisible, + cond(neq(this.nextIsVisible, UNSET), [ + // Stop any running animations + cond(clockRunning(this.clock), stopClock(this.clock)), + set(this.gesture, 0), + // Update the index to trigger the transition + set(this.isVisible, this.nextIsVisible), + set(this.nextIsVisible, UNSET), + ]) + ), + onChange( + this.isSwiping, + call( + [this.isSwiping, this.isSwipeCancelled], + ([isSwiping, isSwipeCancelled]: readonly Binary[]) => { + const { + onGestureBegin, + onGestureEnd, + onGestureCanceled, + } = this.props; + + if (isSwiping === TRUE) { + onGestureBegin && onGestureBegin(); + } else { + if (isSwipeCancelled === TRUE) { + onGestureCanceled && onGestureCanceled(); + } else { + onGestureEnd && onGestureEnd(); + } + } + } + ) + ), + // Synchronize the translation with the animated value representing the progress + set( + this.props.current, + cond( + or(eq(this.layout.width, 0), eq(this.layout.height, 0)), + this.isVisible, + interpolate(this.position, { + inputRange: [0, this.distance], + outputRange: [1, 0], + }) + ) + ), + cond( + eq(this.gestureState, GestureState.ACTIVE), + [ + cond(this.isSwiping, NOOP, [ + // We weren't dragging before, set it to true + set(this.isSwipeCancelled, FALSE), + set(this.isSwiping, TRUE), + set(this.isSwipeGesture, TRUE), + // Also update the drag offset to the last position + set(this.offset, this.position), + ]), + // Update position with next offset + gesture distance + set(this.position, max(add(this.offset, this.gesture), 0)), + // Stop animations while we're dragging + stopClock(this.clock), + ], + [ + set( + this.isSwipeCancelled, + eq(this.gestureState, GestureState.CANCELLED) + ), + set(this.isSwiping, FALSE), + this.runTransition( + cond( + or( + and( + greaterThan(abs(this.gesture), SWIPE_DISTANCE_MINIMUM), + greaterThan( + abs(this.velocity), + SWIPE_VELOCITY_THRESHOLD_DEFAULT + ) + ), + cond( + greaterThan( + abs(this.gesture), + SWIPE_DISTANCE_THRESHOLD_DEFAULT + ), + TRUE, + FALSE + ) + ), + cond( + lessThan( + cond(eq(this.velocity, 0), this.gesture, this.velocity), + 0 + ), + TRUE, + FALSE + ), + this.isVisible + ) + ), + ] + ), + this.position, + ]); + + private handleGestureEventHorizontal = Animated.event([ + { + nativeEvent: { + translationX: this.gesture, + velocityX: this.velocity, + state: this.gestureState, + }, + }, + ]); + + private handleGestureEventVertical = Animated.event([ + { + nativeEvent: { + translationY: this.gesture, + velocityY: this.velocity, + state: this.gestureState, + }, + }, + ]); + + // We need to ensure that this style doesn't change unless absolutely needs to + // Changing it too often will result in huge frame drops due to detaching and attaching + // Changing it during an animations can result in unexpected results + private getInterpolatedStyle = memoize( + ( + styleInterpolator: CardStyleInterpolator, + current: Animated.Node, + next: Animated.Node | undefined, + layout: Layout + ) => + styleInterpolator({ + progress: { + current, + next, + }, + closing: this.isClosing, + layouts: { + screen: layout, + }, + }) + ); + + private gestureActivationCriteria() { + const { layout, direction, gestureResponseDistance } = this.props; + + // Doesn't make sense for a response distance of 0, so this works fine + const distance = + direction === 'vertical' + ? (gestureResponseDistance && gestureResponseDistance.vertical) || + GESTURE_RESPONSE_DISTANCE_VERTICAL + : (gestureResponseDistance && gestureResponseDistance.horizontal) || + GESTURE_RESPONSE_DISTANCE_HORIZONTAL; + + if (direction === 'vertical') { + return { + maxDeltaX: 15, + minOffsetY: 5, + hitSlop: { bottom: -layout.height + distance }, + }; + } else { + return { + minOffsetX: 5, + maxDeltaY: 20, + hitSlop: { right: -layout.width + distance }, + }; + } + } + + private gestureRef: React.Ref = React.createRef(); + + render() { + const { + transparent, + layout, + current, + next, + direction, + gesturesEnabled, + children, + styleInterpolator, + ...rest + } = this.props; + + const { + containerStyle, + cardStyle, + overlayStyle, + } = this.getInterpolatedStyle(styleInterpolator, current, next, layout); + + const handleGestureEvent = + direction === 'vertical' + ? this.handleGestureEventVertical + : this.handleGestureEventHorizontal; + + return ( + + + + {overlayStyle ? ( + + ) : null} + + + + {children} + + + + + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + }, + card: { + ...StyleSheet.absoluteFillObject, + shadowOffset: { width: -1, height: 1 }, + shadowRadius: 5, + shadowColor: '#000', + backgroundColor: 'white', + elevation: 2, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#000', + }, + transparent: { + backgroundColor: 'transparent', + shadowOpacity: 0, + }, +}); diff --git a/packages/stack/src/views/Stack/Stack.tsx b/packages/stack/src/views/Stack/Stack.tsx new file mode 100755 index 00000000..7a980479 --- /dev/null +++ b/packages/stack/src/views/Stack/Stack.tsx @@ -0,0 +1,283 @@ +import * as React from 'react'; +import { View, StyleSheet, LayoutChangeEvent, Dimensions } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { getDefaultHeaderHeight } from '../Header/HeaderSegment'; +import HeaderContainer from '../Header/HeaderContainer'; +import Card from './Card'; +import { + Route, + Layout, + TransitionSpec, + CardStyleInterpolator, + HeaderStyleInterpolator, + HeaderMode, + GestureDirection, + SceneDescriptor, + NavigationProp, + HeaderScene, +} from '../../types'; + +type ProgressValues = { + [key: string]: Animated.Value; +}; + +type Props = { + navigation: NavigationProp; + descriptors: { [key: string]: SceneDescriptor }; + routes: Route[]; + openingRoutes: string[]; + closingRoutes: string[]; + onGoBack: (props: { route: Route }) => void; + onOpenRoute: (props: { route: Route }) => void; + onCloseRoute: (props: { route: Route }) => void; + getGesturesEnabled: (props: { route: Route }) => boolean; + renderScene: (props: { route: Route }) => React.ReactNode; + transparentCard?: boolean; + headerMode: HeaderMode; + direction: GestureDirection; + onTransitionStart?: ( + curr: { index: number }, + prev: { index: number } + ) => void; + onGestureBegin?: () => void; + onGestureCanceled?: () => void; + onGestureEnd?: () => void; + transitionSpec: { + open: TransitionSpec; + close: TransitionSpec; + }; + cardStyleInterpolator: CardStyleInterpolator; + headerStyleInterpolator: HeaderStyleInterpolator; +}; + +type State = { + routes: Route[]; + scenes: HeaderScene[]; + progress: ProgressValues; + layout: Layout; + floaingHeaderHeight: number; +}; + +const dimensions = Dimensions.get('window'); +const layout = { width: dimensions.width, height: dimensions.height }; + +export default class Stack extends React.Component { + static getDerivedStateFromProps(props: Props, state: State) { + if (props.routes === state.routes) { + return null; + } + + const progress = props.routes.reduce( + (acc, curr) => { + acc[curr.key] = + state.progress[curr.key] || + new Animated.Value(props.openingRoutes.includes(curr.key) ? 0 : 1); + + return acc; + }, + {} as ProgressValues + ); + + return { + routes: props.routes, + scenes: props.routes.map((route, index, self) => { + const previousRoute = self[index - 1]; + const nextRoute = self[index + 1]; + + const current = progress[route.key]; + const previous = previousRoute + ? progress[previousRoute.key] + : undefined; + const next = nextRoute ? progress[nextRoute.key] : undefined; + + const scene = { + route, + previous: previousRoute, + descriptor: props.descriptors[route.key], + progress: { + current, + next, + previous, + }, + }; + + const oldScene = state.scenes[index]; + + if ( + oldScene && + scene.route === oldScene.route && + scene.progress.current === oldScene.progress.current && + scene.progress.next === oldScene.progress.next && + scene.progress.previous === oldScene.progress.previous + ) { + return oldScene; + } + + return scene; + }), + progress, + }; + } + + state: State = { + routes: [], + scenes: [], + progress: {}, + layout, + // Used when card's header is null and mode is float to make transition + // between screens with headers and those without headers smooth. + // This is not a great heuristic here. We don't know synchronously + // on mount what the header height is so we have just used the most + // common cases here. + floaingHeaderHeight: getDefaultHeaderHeight(layout), + }; + + private handleLayout = (e: LayoutChangeEvent) => { + const { height, width } = e.nativeEvent.layout; + + if ( + height === this.state.layout.height && + width === this.state.layout.width + ) { + return; + } + + const layout = { width, height }; + + this.setState({ layout }); + }; + + private handleFloatingHeaderLayout = (e: LayoutChangeEvent) => { + const { height } = e.nativeEvent.layout; + + if (height !== this.state.floaingHeaderHeight) { + this.setState({ floaingHeaderHeight: height }); + } + }; + + render() { + const { + descriptors, + navigation, + routes, + openingRoutes, + closingRoutes, + onOpenRoute, + onCloseRoute, + onGoBack, + getGesturesEnabled, + renderScene, + transparentCard, + headerMode, + direction, + onTransitionStart, + onGestureBegin, + onGestureCanceled, + onGestureEnd, + transitionSpec, + cardStyleInterpolator, + headerStyleInterpolator, + } = this.props; + + const { scenes, layout, progress, floaingHeaderHeight } = this.state; + const focusedRoute = navigation.state.routes[navigation.state.index]; + + return ( + + + {routes.map((route, index) => { + const focused = focusedRoute.key === route.key; + const current = progress[route.key]; + const descriptor = descriptors[route.key]; + const scene = scenes[index]; + + return ( + onOpenRoute({ route })} + onClose={() => onCloseRoute({ route })} + animateIn={openingRoutes.includes(route.key)} + gesturesEnabled={getGesturesEnabled({ route })} + onTransitionStart={({ closing }) => { + onTransitionStart && + onTransitionStart( + { index: closing ? index - 1 : index }, + { index } + ); + + closing && onGoBack({ route }); + }} + onGestureBegin={onGestureBegin} + onGestureCanceled={onGestureCanceled} + onGestureEnd={onGestureEnd} + gestureResponseDistance={ + descriptor.options.gestureResponseDistance + } + transitionSpec={transitionSpec} + styleInterpolator={cardStyleInterpolator} + accessibilityElementsHidden={!focused} + importantForAccessibility={ + focused ? 'auto' : 'no-hide-descendants' + } + pointerEvents="box-none" + style={[ + StyleSheet.absoluteFill, + headerMode === 'float' && + descriptor && + descriptor.options.header !== null + ? { marginTop: floaingHeaderHeight } + : null, + ]} + > + {headerMode === 'screen' ? ( + + ) : null} + {renderScene({ route })} + + ); + })} + + {headerMode === 'float' ? ( + + ) : null} + + ); + } +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + overflow: 'hidden', + }, + header: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}); diff --git a/packages/stack/src/views/Stack/StackView.tsx b/packages/stack/src/views/Stack/StackView.tsx new file mode 100644 index 00000000..34a7a6f5 --- /dev/null +++ b/packages/stack/src/views/Stack/StackView.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { SceneView, StackActions } from '@react-navigation/core'; +import Stack from './Stack'; +import { + DefaultTransition, + ModalSlideFromBottomIOS, +} from '../../TransitionConfigs/TransitionPresets'; +import { + NavigationProp, + SceneDescriptor, + NavigationConfig, + Route, +} from '../../types'; +import { Platform } from 'react-native'; + +type Props = { + navigation: NavigationProp; + descriptors: { [key: string]: SceneDescriptor }; + navigationConfig: NavigationConfig; + onTransitionStart?: ( + curr: { index: number }, + prev: { index: number } + ) => void; + onGestureBegin?: () => void; + onGestureCanceled?: () => void; + onGestureEnd?: () => void; + screenProps?: unknown; +}; + +type State = { + routes: Route[]; + descriptors: { [key: string]: SceneDescriptor }; +}; + +class StackView extends React.Component { + static getDerivedStateFromProps( + props: Readonly, + state: Readonly + ) { + const { navigation } = props; + const { transitions } = navigation.state; + + let { routes } = navigation.state; + + if (transitions.pushing.length) { + // If there are multiple routes being pushed/popped, we'll encounter glitches + // Only keep one screen animating at a time to avoid this + const toFilter = transitions.popping.length + ? // If there are screens popping, we want to defer pushing of all screens + transitions.pushing + : transitions.pushing.length > 1 + ? // If there are more than 1 screens pushing, we want to defer pushing all except the first + transitions.pushing.slice(1) + : undefined; + + if (toFilter) { + routes = routes.filter(route => !toFilter.includes(route.key)); + } + } + + if (transitions.popping.length) { + // Get indices of routes that were removed so we can preserve their position when transitioning away + const indices = state.routes.reduce( + (acc, curr, index) => { + if (transitions.popping.includes(curr.key)) { + acc.push([curr, index]); + } + + return acc; + }, + [] as Array<[Route, number]> + ); + + if (indices.length) { + routes = routes.slice(); + indices.forEach(([route, index]) => { + routes.splice(index, 0, route); + }); + } + } + + return { + routes, + descriptors: { ...state.descriptors, ...props.descriptors }, + }; + } + + state: State = { + routes: this.props.navigation.state.routes, + descriptors: {}, + }; + + private getGesturesEnabled = ({ route }: { route: Route }) => { + const { routes } = this.props.navigation.state; + + const isFirst = routes[0].key === route.key; + const isLast = routes[routes.length - 1].key === route.key; + + if (isFirst || !isLast) { + return false; + } + + const descriptor = this.state.descriptors[route.key]; + + return descriptor && descriptor.options.gesturesEnabled !== undefined + ? descriptor.options.gesturesEnabled + : Platform.OS !== 'android'; + }; + + private renderScene = ({ route }: { route: Route }) => { + const descriptor = this.state.descriptors[route.key]; + + if (!descriptor) { + return null; + } + + const { navigation, getComponent } = descriptor; + const SceneComponent = getComponent(); + + const { screenProps } = this.props; + + return ( + + ); + }; + + private handleGoBack = ({ route }: { route: Route }) => + this.props.navigation.dispatch(StackActions.pop({ key: route.key })); + + private handleTransitionComplete = ({ route }: { route: Route }) => { + this.props.navigation.dispatch( + StackActions.completeTransition({ toChildKey: route.key }) + ); + }; + + private handleOpenRoute = ({ route }: { route: Route }) => { + this.handleTransitionComplete({ route }); + }; + + private handleCloseRoute = ({ route }: { route: Route }) => { + // @ts-ignore + this.setState(state => ({ + routes: state.routes.filter(r => r.key !== route.key), + descriptors: { ...state.descriptors, [route.key]: undefined }, + })); + + this.props.navigation.dispatch( + StackActions.pop({ key: route.key, immediate: true }) + ); + + this.handleTransitionComplete({ route }); + }; + + render() { + const { + navigation, + navigationConfig, + onTransitionStart, + onGestureBegin, + onGestureCanceled, + onGestureEnd, + } = this.props; + + const { mode, ...config } = navigationConfig; + const { pushing, popping } = navigation.state.transitions; + + const { routes, descriptors } = this.state; + + const headerMode = + mode !== 'modal' && Platform.OS === 'ios' ? 'float' : 'screen'; + + const transitionPreset = + mode === 'modal' && Platform.OS === 'ios' + ? ModalSlideFromBottomIOS + : DefaultTransition; + + return ( + + ); + } +} + +export default StackView; diff --git a/packages/stack/src/views/Stack/Swipeable.tsx b/packages/stack/src/views/Stack/Swipeable.tsx new file mode 100644 index 00000000..e6a79f9f --- /dev/null +++ b/packages/stack/src/views/Stack/Swipeable.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { PanGestureHandler } from 'react-native-gesture-handler'; +import StackGestureContext from '../../utils/StackGestureContext'; +import { Layout } from '../../types'; + +type Props = ViewProps & { + gesture: Animated.Value; + velocity: Animated.Value; + gestureState: Animated.Value; + layout: Layout; + direction: 'horizontal' | 'vertical'; + gesturesEnabled: boolean; + gestureResponseDistance?: { + vertical?: number; + horizontal?: number; + }; + children: React.ReactNode; +}; + +/** + * The distance of touch start from the edge of the screen where the gesture will be recognized + */ +const GESTURE_RESPONSE_DISTANCE_HORIZONTAL = 50; +const GESTURE_RESPONSE_DISTANCE_VERTICAL = 135; + +export default class Swipeable extends React.Component { + private handleGestureEventHorizontal = Animated.event([ + { + nativeEvent: { + translationX: this.props.gesture, + velocityX: this.props.velocity, + state: this.props.gestureState, + }, + }, + ]); + + private handleGestureEventVertical = Animated.event([ + { + nativeEvent: { + translationY: this.props.gesture, + velocityY: this.props.velocity, + state: this.props.gestureState, + }, + }, + ]); + + private gestureActivationCriteria() { + const { layout, direction, gestureResponseDistance } = this.props; + + // Doesn't make sense for a response distance of 0, so this works fine + const distance = + direction === 'vertical' + ? (gestureResponseDistance && gestureResponseDistance.vertical) || + GESTURE_RESPONSE_DISTANCE_VERTICAL + : (gestureResponseDistance && gestureResponseDistance.horizontal) || + GESTURE_RESPONSE_DISTANCE_HORIZONTAL; + + if (direction === 'vertical') { + return { + maxDeltaX: 15, + minOffsetY: 5, + hitSlop: { bottom: -layout.height + distance }, + }; + } else { + return { + minOffsetX: 5, + maxDeltaY: 20, + hitSlop: { right: -layout.width + distance }, + }; + } + } + + private gestureRef: React.Ref = React.createRef(); + + render() { + const { + layout, + direction, + gesturesEnabled, + children, + ...rest + } = this.props; + + const handleGestureEvent = + direction === 'vertical' + ? this.handleGestureEventVertical + : this.handleGestureEventHorizontal; + + return ( + + + {children} + + + ); + } +} diff --git a/packages/stack/src/views/TouchableItem.tsx b/packages/stack/src/views/TouchableItem.tsx new file mode 100644 index 00000000..f1296514 --- /dev/null +++ b/packages/stack/src/views/TouchableItem.tsx @@ -0,0 +1,82 @@ +/** + * TouchableItem renders a touchable that looks native on both iOS and Android. + * + * It provides an abstraction on top of TouchableNativeFeedback and + * TouchableOpacity. + * + * On iOS you can pass the props of TouchableOpacity, on Android pass the props + * of TouchableNativeFeedback. + */ +import * as React from 'react'; +import { + Platform, + TouchableNativeFeedback, + TouchableOpacity, + View, + ViewProps, +} from 'react-native'; + +import BorderlessButton from './BorderlessButton'; + +type Props = ViewProps & { + pressColor: string; + disabled?: boolean; + borderless?: boolean; + delayPressIn?: number; + onPress?: () => void; +}; + +const ANDROID_VERSION_LOLLIPOP = 21; + +export default class TouchableItem extends React.Component { + static defaultProps = { + borderless: false, + pressColor: 'rgba(0, 0, 0, .32)', + }; + + render() { + /* + * TouchableNativeFeedback.Ripple causes a crash on old Android versions, + * therefore only enable it on Android Lollipop and above. + * + * All touchables on Android should have the ripple effect according to + * platform design guidelines. + * We need to pass the background prop to specify a borderless ripple effect. + */ + if ( + Platform.OS === 'android' && + Platform.Version >= ANDROID_VERSION_LOLLIPOP + ) { + const { style, ...rest } = this.props; + return ( + + {React.Children.only(this.props.children)} + + ); + } else if (Platform.OS === 'ios') { + return ( + + {this.props.children} + + ); + } else { + return ( + + {this.props.children} + + ); + } + } +} diff --git a/packages/stack/src/views/assets/back-icon-mask.png b/packages/stack/src/views/assets/back-icon-mask.png new file mode 100644 index 00000000..dbddbdff Binary files /dev/null and b/packages/stack/src/views/assets/back-icon-mask.png differ diff --git a/packages/stack/src/views/assets/back-icon.png b/packages/stack/src/views/assets/back-icon.png new file mode 100644 index 00000000..8ed2ec42 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon.png differ diff --git a/packages/stack/src/views/assets/back-icon@1.5x.android.png b/packages/stack/src/views/assets/back-icon@1.5x.android.png new file mode 100644 index 00000000..ad03a63b Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@1.5x.android.png differ diff --git a/packages/stack/src/views/assets/back-icon@1.5x.ios.png b/packages/stack/src/views/assets/back-icon@1.5x.ios.png new file mode 100644 index 00000000..1fa30ec2 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@1.5x.ios.png differ diff --git a/packages/stack/src/views/assets/back-icon@1x.android.png b/packages/stack/src/views/assets/back-icon@1x.android.png new file mode 100644 index 00000000..083db295 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@1x.android.png differ diff --git a/packages/stack/src/views/assets/back-icon@1x.ios.png b/packages/stack/src/views/assets/back-icon@1x.ios.png new file mode 100644 index 00000000..8ed2ec42 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@1x.ios.png differ diff --git a/packages/stack/src/views/assets/back-icon@2x.android.png b/packages/stack/src/views/assets/back-icon@2x.android.png new file mode 100644 index 00000000..6de0a1cb Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@2x.android.png differ diff --git a/packages/stack/src/views/assets/back-icon@2x.ios.png b/packages/stack/src/views/assets/back-icon@2x.ios.png new file mode 100644 index 00000000..63de0e30 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@2x.ios.png differ diff --git a/packages/stack/src/views/assets/back-icon@3x.android.png b/packages/stack/src/views/assets/back-icon@3x.android.png new file mode 100644 index 00000000..15a983a6 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@3x.android.png differ diff --git a/packages/stack/src/views/assets/back-icon@3x.ios.png b/packages/stack/src/views/assets/back-icon@3x.ios.png new file mode 100644 index 00000000..2320fd74 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@3x.ios.png differ diff --git a/packages/stack/src/views/assets/back-icon@4x.android.png b/packages/stack/src/views/assets/back-icon@4x.android.png new file mode 100644 index 00000000..17e52e85 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@4x.android.png differ diff --git a/packages/stack/src/views/assets/back-icon@4x.ios.png b/packages/stack/src/views/assets/back-icon@4x.ios.png new file mode 100644 index 00000000..790102d4 Binary files /dev/null and b/packages/stack/src/views/assets/back-icon@4x.ios.png differ