Skip to content

Commit

Permalink
Fix safe area stories cut off (#431)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Co-authored-by: Daniel Williams <[email protected]>
  • Loading branch information
3 people authored Feb 25, 2023
1 parent 663fbef commit b4ed276
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 165 deletions.
169 changes: 102 additions & 67 deletions app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -25,22 +27,23 @@ import Addons from './addons/Addons';
import {
getAddonPanelPosition,
getNavigatorPanelPosition,
getPreviewPosition,
getPreviewScale,
getPreviewShadowStyle,
getPreviewStyle,
} from './animation';
import Navigation from './navigation';
import { PREVIEW, ADDONS } from './navigation/constants';
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;
Expand All @@ -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<ViewStyle>;
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 (
<Animated.View style={[flex, containerStyle]}>
<View style={[flex, style]}>{children}</View>
</Animated.View>
);
}

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,
Expand All @@ -82,55 +95,80 @@ 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<PreviewDimens>(() => ({
width: Dimensions.get('window').width,
height: Dimensions.get('window').height,
}));
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<boolean>('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 (
<>
<View style={[flex, IS_ANDROID && IS_EXPO && styles.expoAndroidContainer]}>
<Container>
<KeyboardAvoidingView
enabled={!shouldDisableKeyboardAvoidingView || tabOpen !== PREVIEW}
behavior={IS_IOS ? 'padding' : null}
Expand All @@ -142,47 +180,44 @@ const OnDeviceUI = ({
previewDimensions={previewDimensions}
>
<Animated.View style={previewWrapperStyles}>
<Animated.View style={previewStyles}>
<Preview disabled={tabOpen === PREVIEW}>
<WrapperView style={[flex, wrapperMargin]}>
<StoryView />
</WrapperView>
</Preview>
{tabOpen !== PREVIEW ? (
<TouchableOpacity
style={absolutePosition}
onPress={() => handleToggleTab(PREVIEW)}
/>
) : null}
</Animated.View>
<Preview style={safeAreaMargins} animatedValue={animatedValue.current}>
<StoryView />
</Preview>
{tabOpen !== PREVIEW ? (
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
onPress={() => handleToggleTab(PREVIEW)}
/>
) : null}
</Animated.View>
<Panel
style={getNavigatorPanelPosition(
animatedValue.current,
previewDimensions.width,
wide
)}
style={[
getNavigatorPanelPosition(animatedValue.current, previewDimensions.width, wide),
safeAreaMargins,
{ backgroundColor: theme.storyListBackgroundColor },
]}
>
<StoryListView storyIndex={storyIndex} />
</Panel>

<Panel
style={[
getAddonPanelPosition(animatedValue.current, previewDimensions.width, wide),
wrapperMargin,
safeAreaMargins,
]}
>
<Addons active={tabOpen === ADDONS} />
</Panel>
</AbsolutePositionedKeyboardAwareView>
</KeyboardAvoidingView>
<Navigation
onLayout={measureNavigation}
tabOpen={tabOpen}
onChangeTab={handleToggleTab}
isUIVisible={isUIVisible}
setIsUIVisible={setIsUIVisible}
/>
</View>
</Container>
</>
);
};
Expand Down
Loading

0 comments on commit b4ed276

Please sign in to comment.