From b4ed2769365423e1f4d1a8eed2bfe7125c35ebf7 Mon Sep 17 00:00:00 2001 From: Jonathan Jacobs Date: Sat, 25 Feb 2023 20:44:50 +0200 Subject: [PATCH] Fix safe area stories cut off (#431) * fix: Avoid cutting off content in `noSafeArea: false` story content Stories that use `noSafeArea: true` are unaffected. * chore: Prettier formatting in changed files * feat: Improve the preview scaling/translating behaviour In order to create more a more stable animation, one that isn't affected by safe areas popping in/out, a few refactorings were necessary: - Remove the use of `SafeAreaView` and instead manually manage the margin using the values from `useSafeAreaInsets` - Use a stable Y-translation for the scaled preview, to ensure it is at the same position regardless of safe area presence, or UI visibility Some additional improvements: - Center the scaled preview horizontally in the available space, instead of using a fixed pixel offset (TRANSLATE_X_OFFSET) - Reduce the number of views and animated views necessary to achieve the animation - Add a shadow to the scaled preview to indicate the device screen area in relation to the story content area - Measure the navigation bar height, instead of using a fixed value that is differently incorrect on each platform - Add comments to code where explaining something not obvious could help the next developer * fix: Dark mode on iOS causing a black preview background * commit: fix: Safe area not being accounted for in the addons panel This unifies the special behaviour regarding safe area insets in `StoryListView`. --------- Co-authored-by: Jonathan Jacobs Co-authored-by: Daniel Williams --- .../components/OnDeviceUI/OnDeviceUI.tsx | 169 +++++++++++------- .../components/OnDeviceUI/animation.ts | 86 +++++---- .../OnDeviceUI/navigation/Navigation.tsx | 7 +- .../StoryListView/StoryListView.tsx | 112 +++++------- .../SafeAreaExample/UsableArea.stories.tsx | 32 ++++ 5 files changed, 241 insertions(+), 165 deletions(-) create mode 100644 examples/native/components/SafeAreaExample/UsableArea.stories.tsx diff --git a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx index a44d9fda04..a15c174920 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx +++ b/app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx @@ -1,18 +1,20 @@ -import styled from '@emotion/native'; import { StoryIndex } from '@storybook/client-api'; +import styled from '@emotion/native'; +import { useTheme } from 'emotion-theming'; import React, { useState, useRef } from 'react'; import { Animated, Dimensions, - FlexStyle, + Easing, Keyboard, KeyboardAvoidingView, Platform, - SafeAreaView, TouchableOpacity, StatusBar, StyleSheet, View, + ViewStyle, + StyleProp, } from 'react-native'; import { useStoryContextParam } from '../../../hooks'; import StoryListView from '../StoryListView'; @@ -25,8 +27,8 @@ import Addons from './addons/Addons'; import { getAddonPanelPosition, getNavigatorPanelPosition, - getPreviewPosition, - getPreviewScale, + getPreviewShadowStyle, + getPreviewStyle, } from './animation'; import Navigation from './navigation'; import { PREVIEW, ADDONS } from './navigation/constants'; @@ -34,13 +36,14 @@ import Panel from './Panel'; import { useWindowDimensions } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const ANIMATION_DURATION = 300; +const ANIMATION_DURATION = 400; const IS_IOS = Platform.OS === 'ios'; // @ts-ignore: Property 'Expo' does not exist on type 'Global' const getExpoRoot = () => global.Expo || global.__expo || global.__exponent; export const IS_EXPO = getExpoRoot() !== undefined; const IS_ANDROID = Platform.OS === 'android'; const BREAKPOINT = 1024; + interface OnDeviceUIProps { storyIndex: StoryIndex; url?: string; @@ -52,28 +55,38 @@ interface OnDeviceUIProps { const flex = { flex: 1 }; -const Preview = styled.View<{ disabled: boolean }>(flex, ({ disabled, theme }) => ({ - borderLeftWidth: disabled ? 0 : 1, - borderTopWidth: disabled ? 0 : 1, - borderRightWidth: disabled ? 0 : 1, - borderBottomWidth: disabled ? 0 : 1, - borderColor: disabled ? 'transparent' : theme.previewBorderColor, - borderRadius: disabled ? 0 : 12, - overflow: 'hidden', -})); +interface PreviewProps { + animatedValue: Animated.Value; + style: StyleProp; + children?: React.ReactNode; +} -const absolutePosition: FlexStyle = { - position: 'absolute', - top: 0, - bottom: 0, - left: 0, - right: 0, -}; +/** + * Story preview container. + */ +function Preview({ animatedValue, style, children }: PreviewProps) { + const theme: any = useTheme(); + const containerStyle = { + backgroundColor: theme.backgroundColor, + ...getPreviewShadowStyle(animatedValue), + }; + return ( + + {children} + + ); +} const styles = StyleSheet.create({ expoAndroidContainer: { paddingTop: StatusBar.currentHeight }, }); +const Container = styled.View(({ theme }) => ({ + flex: 1, + backgroundColor: theme.backgroundColor, + ...(IS_ANDROID && IS_EXPO ? styles.expoAndroidContainer : undefined), +})); + const OnDeviceUI = ({ storyIndex, isUIHidden, @@ -82,7 +95,7 @@ const OnDeviceUI = ({ tabOpen: initialTabOpen, }: OnDeviceUIProps) => { const [tabOpen, setTabOpen] = useState(initialTabOpen || PREVIEW); - const [slideBetweenAnimation, setSlideBetweenAnimation] = useState(false); + const lastTabOpen = React.useRef(tabOpen); const [previewDimensions, setPreviewDimensions] = useState(() => ({ width: Dimensions.get('window').width, height: Dimensions.get('window').height, @@ -90,47 +103,72 @@ const OnDeviceUI = ({ const animatedValue = useRef(new Animated.Value(tabOpen)); const wide = useWindowDimensions().width >= BREAKPOINT; const insets = useSafeAreaInsets(); + const theme: any = useTheme(); const [isUIVisible, setIsUIVisible] = useState(isUIHidden !== undefined ? !isUIHidden : true); - const handleToggleTab = React.useCallback((newTabOpen: number) => { - if (newTabOpen === tabOpen) { - return; - } - Animated.timing(animatedValue.current, { - toValue: newTabOpen, - duration: ANIMATION_DURATION, - useNativeDriver: true, - }).start(); - setTabOpen(newTabOpen); - const isSwipingBetweenNavigatorAndAddons = tabOpen + newTabOpen === PREVIEW; - setSlideBetweenAnimation(isSwipingBetweenNavigatorAndAddons); + const handleToggleTab = React.useCallback( + (newTabOpen: number) => { + if (newTabOpen === tabOpen) { + return; + } + lastTabOpen.current = tabOpen; + Animated.timing(animatedValue.current, { + toValue: newTabOpen, + duration: ANIMATION_DURATION, + easing: Easing.inOut(Easing.cubic), + useNativeDriver: true, + }).start(); + setTabOpen(newTabOpen); - // close the keyboard opened from a TextInput from story list or knobs - if (newTabOpen === PREVIEW) { - Keyboard.dismiss(); - } - }, [tabOpen]); + // close the keyboard opened from a TextInput from story list or knobs + if (newTabOpen === PREVIEW) { + Keyboard.dismiss(); + } + }, + [tabOpen] + ); const noSafeArea = useStoryContextParam('noSafeArea', false); const previewWrapperStyles = [ flex, - getPreviewPosition({ + getPreviewStyle({ animatedValue: animatedValue.current, previewDimensions, - slideBetweenAnimation, wide, - noSafeArea, insets, + tabOpen, + lastTabOpen: lastTabOpen.current, }), ]; - const previewStyles = [flex, getPreviewScale(animatedValue.current, slideBetweenAnimation, wide)]; + // The initial value is just a guess until the layout calculation has been done. + const [navBarHeight, setNavBarHeight] = React.useState(insets.bottom + 40); + const measureNavigation = React.useCallback( + ({ nativeEvent }) => { + const inset = insets.bottom; + setNavBarHeight(isUIVisible ? nativeEvent.layout.height - inset : 0); + }, + [isUIVisible, insets] + ); - const WrapperView = noSafeArea ? View : SafeAreaView; - const wrapperMargin = { marginBottom: isUIVisible ? insets.bottom + 40 : 0 }; + // There are 4 cases for the additional UI margin: + // 1. Storybook UI is visible, and `noSafeArea` is false: Include top and + // bottom safe area insets, and also include the navigation bar height. + // + // 2. Storybook UI is not visible, and `noSafeArea` is false: Include top + // and bottom safe area insets. + // + // 3. Storybook UI is visible, and `noSafeArea` is true: Include only the + // bottom safe area inset and the navigation bar height. + // + // 4. Storybook UI is not visible, and `noSafeArea` is true: No margin. + const safeAreaMargins = { + paddingBottom: isUIVisible ? insets.bottom + navBarHeight : noSafeArea ? 0 : insets.bottom, + paddingTop: !noSafeArea ? insets.top : 0, + }; return ( <> - + - - - - - - - {tabOpen !== PREVIEW ? ( - handleToggleTab(PREVIEW)} - /> - ) : null} - + + + + {tabOpen !== PREVIEW ? ( + handleToggleTab(PREVIEW)} + /> + ) : null} @@ -169,7 +203,7 @@ const OnDeviceUI = ({ @@ -177,12 +211,13 @@ const OnDeviceUI = ({ - + ); }; diff --git a/app/react-native/src/preview/components/OnDeviceUI/animation.ts b/app/react-native/src/preview/components/OnDeviceUI/animation.ts index e2fd9be822..4935d7a517 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/animation.ts +++ b/app/react-native/src/preview/components/OnDeviceUI/animation.ts @@ -1,17 +1,20 @@ -import { Animated, I18nManager } from 'react-native'; -import { EdgeInsets } from 'react-native-safe-area-context'; +import { Animated, I18nManager, Insets } from 'react-native'; import { PreviewDimens } from './absolute-positioned-keyboard-aware-view'; import { NAVIGATOR, PREVIEW, ADDONS } from './navigation/constants'; +// Factor that will flip the animation orientation in RTL locales. const RTL_SCALE = I18nManager.isRTL ? -1 : 1; +// Percentage to scale the preview area by when opening a panel. const PREVIEW_SCALE = 0.3; -const PREVIEW_WIDE_SCREEN = 0.7; +// Percentage to scale the preview area by when opening a panel, on wide screens. +const PREVIEW_SCALE_WIDE = 0.7; +// Percentage to shrink the visible preview by, without affecting the panel size. +const PREVIEW_SCALE_SHRINK = 0.9; const SCALE_OFFSET = 0.025; -const TRANSLATE_X_OFFSET = 6; const TRANSLATE_Y_OFFSET = 12; const panelWidth = (width: number, wide: boolean) => { - const scale = wide ? PREVIEW_WIDE_SCREEN : PREVIEW_SCALE; + const scale = wide ? PREVIEW_SCALE_WIDE : PREVIEW_SCALE; return width * (1 - scale - SCALE_OFFSET); }; @@ -46,7 +49,10 @@ export const getAddonPanelPosition = ( { translateX: animatedValue.interpolate({ inputRange: [PREVIEW, ADDONS], - outputRange: [previewWidth * RTL_SCALE, (previewWidth - panelWidth(previewWidth, wide)) * RTL_SCALE], + outputRange: [ + previewWidth * RTL_SCALE, + (previewWidth - panelWidth(previewWidth, wide)) * RTL_SCALE, + ], }), }, ], @@ -58,25 +64,39 @@ export const getAddonPanelPosition = ( type PreviewPositionArgs = { animatedValue: Animated.Value; previewDimensions: PreviewDimens; - slideBetweenAnimation: boolean; wide: boolean; - noSafeArea: boolean; - insets: EdgeInsets; + insets: Insets; + tabOpen: number; + lastTabOpen: number; }; -export const getPreviewPosition = ({ +/** + * Build the animated style for the preview container view. + * + * When the navigator or addons panel is focused, the preview container is + * scaled down and translated to the left (or right) of the panel. + */ +export const getPreviewStyle = ({ animatedValue, previewDimensions: { width: previewWidth, height: previewHeight }, - slideBetweenAnimation, wide, - noSafeArea, insets, + tabOpen, + lastTabOpen, }: PreviewPositionArgs) => { - const scale = wide ? PREVIEW_WIDE_SCREEN : PREVIEW_SCALE; - const translateX = (previewWidth / 2 - (previewWidth * scale) / 2 - TRANSLATE_X_OFFSET) * RTL_SCALE; - const marginTop = noSafeArea ? 0 : insets.top; + const scale = (wide ? PREVIEW_SCALE_WIDE : PREVIEW_SCALE) * PREVIEW_SCALE_SHRINK; + const scaledPreviewWidth = previewWidth * scale; + const scaledPreviewHeight = previewHeight * scale; + // Horizontally center the scaled preview in the available space beside the panel. + const nonPanelWidth = previewWidth - panelWidth(previewWidth, wide); + const translateXOffset = (nonPanelWidth - scaledPreviewWidth) / 2; + const translateX = (previewWidth / 2 - (previewWidth * scale) / 2 - translateXOffset) * RTL_SCALE; + // Translate the preview to the top edge of the screen, move it down by the + // safe area inset, then by the preview Y offset. const translateY = - -(previewHeight / 2 - (previewHeight * scale) / 2 - TRANSLATE_Y_OFFSET) + marginTop; + -(previewHeight / 2 - scaledPreviewHeight / 2) + insets.top + TRANSLATE_Y_OFFSET; + // Is navigation moving from one panel to another, skipping preview? + const skipPreview = lastTabOpen !== PREVIEW && tabOpen !== PREVIEW; return { transform: [ @@ -89,27 +109,33 @@ export const getPreviewPosition = ({ { translateY: animatedValue.interpolate({ inputRange: [NAVIGATOR, PREVIEW, ADDONS], - outputRange: [translateY, slideBetweenAnimation ? translateY : marginTop, translateY], + outputRange: [translateY, skipPreview ? translateY : 0, translateY], }), }, - ], - }; -}; - -export const getPreviewScale = ( - animatedValue: Animated.Value, - slideBetweenAnimation: boolean, - wide: boolean -) => { - const scale = wide ? PREVIEW_WIDE_SCREEN : PREVIEW_SCALE; - return { - transform: [ { scale: animatedValue.interpolate({ inputRange: [NAVIGATOR, PREVIEW, ADDONS], - outputRange: [scale, slideBetweenAnimation ? scale : 1, scale], + outputRange: [scale, skipPreview ? scale : 1, scale], }), }, ], }; }; + +/** + * Build the animated shadow style for the preview. + * + * When the navigator or addons panel are visible the scaled preview will have + * a shadow, and when going to the preview tab the shadow will be invisible. + */ +export const getPreviewShadowStyle = (animatedValue: Animated.Value) => ({ + elevation: 8, + shadowColor: '#000', + shadowOpacity: animatedValue.interpolate({ + inputRange: [NAVIGATOR, PREVIEW, ADDONS], + outputRange: [0.25, 0, 0.25], + }), + shadowRadius: 8, + shadowOffset: { width: 0, height: 0 }, + overflow: 'visible' as const, +}); diff --git a/app/react-native/src/preview/components/OnDeviceUI/navigation/Navigation.tsx b/app/react-native/src/preview/components/OnDeviceUI/navigation/Navigation.tsx index e7974f0375..27ba330caa 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/navigation/Navigation.tsx +++ b/app/react-native/src/preview/components/OnDeviceUI/navigation/Navigation.tsx @@ -1,5 +1,5 @@ import React, { Dispatch, SetStateAction } from 'react'; -import { View, ViewStyle } from 'react-native'; +import { View, ViewProps, ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import GestureRecognizer from 'react-native-swipe-gestures'; import Bar from './Bar'; @@ -15,6 +15,7 @@ interface Props { onChangeTab: (index: number) => void; isUIVisible: boolean; setIsUIVisible: Dispatch>; + onLayout: ViewProps['onLayout']; } const navStyle: ViewStyle = { @@ -24,7 +25,7 @@ const navStyle: ViewStyle = { bottom: 0, }; -const Navigation = ({ tabOpen, onChangeTab, isUIVisible, setIsUIVisible }: Props) => { +const Navigation = ({ tabOpen, onChangeTab, isUIVisible, setIsUIVisible, onLayout }: Props) => { const insets = useSafeAreaInsets(); const handleToggleUI = () => { @@ -44,7 +45,7 @@ const Navigation = ({ tabOpen, onChangeTab, isUIVisible, setIsUIVisible }: Props }; return ( - + {isUIVisible && ( { ({ - top: 0, - ...StyleSheet.absoluteFillObject, - - // for this to work I need to get the top margin from safeareview context - // shadowColor: '#000', - // shadowOffset: { - // width: 0, - // height: 1, - // }, - // shadowOpacity: 0.2, - // shadowRadius: 1.41, - // elevation: 2, - + flex: 1, borderRightWidth: StyleSheet.hairlineWidth, borderRightColor: theme.borderColor, backgroundColor: theme.storyListBackgroundColor, @@ -121,7 +103,11 @@ interface ListItemProps { isLastItem: boolean; } -const ItemTouchable = styled.TouchableOpacity<{ selected: boolean, sectionSelected: boolean, isLastItem: boolean }>( +const ItemTouchable = styled.TouchableOpacity<{ + selected: boolean; + sectionSelected: boolean; + isLastItem: boolean; +}>( { marginHorizontal: 6, padding: 6, @@ -132,10 +118,10 @@ const ItemTouchable = styled.TouchableOpacity<{ selected: boolean, sectionSelect ({ selected, sectionSelected, isLastItem, theme }) => { return { backgroundColor: selected - ? (theme?.listItemActiveColor ?? '#1ea7fd') + ? theme?.listItemActiveColor ?? '#1ea7fd' : sectionSelected - ? theme?.sectionActiveColor - : undefined, + ? theme?.sectionActiveColor + : undefined, borderBottomLeftRadius: isLastItem ? 6 : undefined, borderBottomRightRadius: isLastItem ? 6 : undefined, }; @@ -195,14 +181,11 @@ const styles = StyleSheet.create({ sectionListContentContainer: { paddingBottom: 6 }, }); -const tabBarHeight = 40; - function keyExtractor(item: any, index) { return item.id + index; } const StoryListView = ({ storyIndex }: Props) => { - const insets = useSafeAreaInsets(); const originalData = useMemo(() => getStories(storyIndex), [storyIndex]); const [data, setData] = useState(originalData); @@ -238,46 +221,45 @@ const StoryListView = ({ storyIndex }: Props) => { channel.emit(Events.SET_CURRENT_STORY, { storyId }); }; - const safeStyle = React.useMemo(() => { - return { flex: 1, marginTop: insets.top, paddingBottom: insets.bottom + tabBarHeight }; - }, [insets]); - - const renderItem: SectionListRenderItem = React.useCallback(({item, index, section}) => { - return ( - changeStory(item.id)} - /> - ); - }, []); + const renderItem: SectionListRenderItem = React.useCallback( + ({ item, index, section }) => { + return ( + changeStory(item.id)} + /> + ); + }, + [] + ); - const renderSectionHeader = React.useCallback(({ section: { title, data } }) => ( - changeStory(data[0].id)} /> - ), []); + const renderSectionHeader = React.useCallback( + ({ section: { title, data } }) => ( + changeStory(data[0].id)} /> + ), + [] + ); return ( - - - - + + ); }; diff --git a/examples/native/components/SafeAreaExample/UsableArea.stories.tsx b/examples/native/components/SafeAreaExample/UsableArea.stories.tsx new file mode 100644 index 0000000000..09bd7cb0aa --- /dev/null +++ b/examples/native/components/SafeAreaExample/UsableArea.stories.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { ComponentStory, ComponentMeta } from '@storybook/react-native'; +import { View, StyleSheet, Text } from 'react-native'; + +const UsableAreaMeta: ComponentMeta = { + title: 'Usable Area', +}; +export default UsableAreaMeta; + +type UsableAreaStory = ComponentStory; + +function UsableAreaContent() { + return ( + + This box should reach all corners of the content area. + + ); +} + +export const SafeArea: UsableAreaStory = () => ; +SafeArea.parameters = { noSafeArea: false }; + +export const NoSafeArea: UsableAreaStory = () => ; +NoSafeArea.parameters = { noSafeArea: true };