Skip to content

Commit b4ed276

Browse files
jonathanjJonathan Jacobsdannyhw
authored
Fix safe area stories cut off (storybookjs#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 <[email protected]> Co-authored-by: Daniel Williams <[email protected]>
1 parent 663fbef commit b4ed276

File tree

5 files changed

+241
-165
lines changed

5 files changed

+241
-165
lines changed

app/react-native/src/preview/components/OnDeviceUI/OnDeviceUI.tsx

Lines changed: 102 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
1-
import styled from '@emotion/native';
21
import { StoryIndex } from '@storybook/client-api';
2+
import styled from '@emotion/native';
3+
import { useTheme } from 'emotion-theming';
34
import React, { useState, useRef } from 'react';
45
import {
56
Animated,
67
Dimensions,
7-
FlexStyle,
8+
Easing,
89
Keyboard,
910
KeyboardAvoidingView,
1011
Platform,
11-
SafeAreaView,
1212
TouchableOpacity,
1313
StatusBar,
1414
StyleSheet,
1515
View,
16+
ViewStyle,
17+
StyleProp,
1618
} from 'react-native';
1719
import { useStoryContextParam } from '../../../hooks';
1820
import StoryListView from '../StoryListView';
@@ -25,22 +27,23 @@ import Addons from './addons/Addons';
2527
import {
2628
getAddonPanelPosition,
2729
getNavigatorPanelPosition,
28-
getPreviewPosition,
29-
getPreviewScale,
30+
getPreviewShadowStyle,
31+
getPreviewStyle,
3032
} from './animation';
3133
import Navigation from './navigation';
3234
import { PREVIEW, ADDONS } from './navigation/constants';
3335
import Panel from './Panel';
3436
import { useWindowDimensions } from 'react-native';
3537
import { useSafeAreaInsets } from 'react-native-safe-area-context';
3638

37-
const ANIMATION_DURATION = 300;
39+
const ANIMATION_DURATION = 400;
3840
const IS_IOS = Platform.OS === 'ios';
3941
// @ts-ignore: Property 'Expo' does not exist on type 'Global'
4042
const getExpoRoot = () => global.Expo || global.__expo || global.__exponent;
4143
export const IS_EXPO = getExpoRoot() !== undefined;
4244
const IS_ANDROID = Platform.OS === 'android';
4345
const BREAKPOINT = 1024;
46+
4447
interface OnDeviceUIProps {
4548
storyIndex: StoryIndex;
4649
url?: string;
@@ -52,28 +55,38 @@ interface OnDeviceUIProps {
5255

5356
const flex = { flex: 1 };
5457

55-
const Preview = styled.View<{ disabled: boolean }>(flex, ({ disabled, theme }) => ({
56-
borderLeftWidth: disabled ? 0 : 1,
57-
borderTopWidth: disabled ? 0 : 1,
58-
borderRightWidth: disabled ? 0 : 1,
59-
borderBottomWidth: disabled ? 0 : 1,
60-
borderColor: disabled ? 'transparent' : theme.previewBorderColor,
61-
borderRadius: disabled ? 0 : 12,
62-
overflow: 'hidden',
63-
}));
58+
interface PreviewProps {
59+
animatedValue: Animated.Value;
60+
style: StyleProp<ViewStyle>;
61+
children?: React.ReactNode;
62+
}
6463

65-
const absolutePosition: FlexStyle = {
66-
position: 'absolute',
67-
top: 0,
68-
bottom: 0,
69-
left: 0,
70-
right: 0,
71-
};
64+
/**
65+
* Story preview container.
66+
*/
67+
function Preview({ animatedValue, style, children }: PreviewProps) {
68+
const theme: any = useTheme();
69+
const containerStyle = {
70+
backgroundColor: theme.backgroundColor,
71+
...getPreviewShadowStyle(animatedValue),
72+
};
73+
return (
74+
<Animated.View style={[flex, containerStyle]}>
75+
<View style={[flex, style]}>{children}</View>
76+
</Animated.View>
77+
);
78+
}
7279

7380
const styles = StyleSheet.create({
7481
expoAndroidContainer: { paddingTop: StatusBar.currentHeight },
7582
});
7683

84+
const Container = styled.View(({ theme }) => ({
85+
flex: 1,
86+
backgroundColor: theme.backgroundColor,
87+
...(IS_ANDROID && IS_EXPO ? styles.expoAndroidContainer : undefined),
88+
}));
89+
7790
const OnDeviceUI = ({
7891
storyIndex,
7992
isUIHidden,
@@ -82,55 +95,80 @@ const OnDeviceUI = ({
8295
tabOpen: initialTabOpen,
8396
}: OnDeviceUIProps) => {
8497
const [tabOpen, setTabOpen] = useState(initialTabOpen || PREVIEW);
85-
const [slideBetweenAnimation, setSlideBetweenAnimation] = useState(false);
98+
const lastTabOpen = React.useRef(tabOpen);
8699
const [previewDimensions, setPreviewDimensions] = useState<PreviewDimens>(() => ({
87100
width: Dimensions.get('window').width,
88101
height: Dimensions.get('window').height,
89102
}));
90103
const animatedValue = useRef(new Animated.Value(tabOpen));
91104
const wide = useWindowDimensions().width >= BREAKPOINT;
92105
const insets = useSafeAreaInsets();
106+
const theme: any = useTheme();
93107
const [isUIVisible, setIsUIVisible] = useState(isUIHidden !== undefined ? !isUIHidden : true);
94108

95-
const handleToggleTab = React.useCallback((newTabOpen: number) => {
96-
if (newTabOpen === tabOpen) {
97-
return;
98-
}
99-
Animated.timing(animatedValue.current, {
100-
toValue: newTabOpen,
101-
duration: ANIMATION_DURATION,
102-
useNativeDriver: true,
103-
}).start();
104-
setTabOpen(newTabOpen);
105-
const isSwipingBetweenNavigatorAndAddons = tabOpen + newTabOpen === PREVIEW;
106-
setSlideBetweenAnimation(isSwipingBetweenNavigatorAndAddons);
109+
const handleToggleTab = React.useCallback(
110+
(newTabOpen: number) => {
111+
if (newTabOpen === tabOpen) {
112+
return;
113+
}
114+
lastTabOpen.current = tabOpen;
115+
Animated.timing(animatedValue.current, {
116+
toValue: newTabOpen,
117+
duration: ANIMATION_DURATION,
118+
easing: Easing.inOut(Easing.cubic),
119+
useNativeDriver: true,
120+
}).start();
121+
setTabOpen(newTabOpen);
107122

108-
// close the keyboard opened from a TextInput from story list or knobs
109-
if (newTabOpen === PREVIEW) {
110-
Keyboard.dismiss();
111-
}
112-
}, [tabOpen]);
123+
// close the keyboard opened from a TextInput from story list or knobs
124+
if (newTabOpen === PREVIEW) {
125+
Keyboard.dismiss();
126+
}
127+
},
128+
[tabOpen]
129+
);
113130

114131
const noSafeArea = useStoryContextParam<boolean>('noSafeArea', false);
115132
const previewWrapperStyles = [
116133
flex,
117-
getPreviewPosition({
134+
getPreviewStyle({
118135
animatedValue: animatedValue.current,
119136
previewDimensions,
120-
slideBetweenAnimation,
121137
wide,
122-
noSafeArea,
123138
insets,
139+
tabOpen,
140+
lastTabOpen: lastTabOpen.current,
124141
}),
125142
];
126143

127-
const previewStyles = [flex, getPreviewScale(animatedValue.current, slideBetweenAnimation, wide)];
144+
// The initial value is just a guess until the layout calculation has been done.
145+
const [navBarHeight, setNavBarHeight] = React.useState(insets.bottom + 40);
146+
const measureNavigation = React.useCallback(
147+
({ nativeEvent }) => {
148+
const inset = insets.bottom;
149+
setNavBarHeight(isUIVisible ? nativeEvent.layout.height - inset : 0);
150+
},
151+
[isUIVisible, insets]
152+
);
128153

129-
const WrapperView = noSafeArea ? View : SafeAreaView;
130-
const wrapperMargin = { marginBottom: isUIVisible ? insets.bottom + 40 : 0 };
154+
// There are 4 cases for the additional UI margin:
155+
// 1. Storybook UI is visible, and `noSafeArea` is false: Include top and
156+
// bottom safe area insets, and also include the navigation bar height.
157+
//
158+
// 2. Storybook UI is not visible, and `noSafeArea` is false: Include top
159+
// and bottom safe area insets.
160+
//
161+
// 3. Storybook UI is visible, and `noSafeArea` is true: Include only the
162+
// bottom safe area inset and the navigation bar height.
163+
//
164+
// 4. Storybook UI is not visible, and `noSafeArea` is true: No margin.
165+
const safeAreaMargins = {
166+
paddingBottom: isUIVisible ? insets.bottom + navBarHeight : noSafeArea ? 0 : insets.bottom,
167+
paddingTop: !noSafeArea ? insets.top : 0,
168+
};
131169
return (
132170
<>
133-
<View style={[flex, IS_ANDROID && IS_EXPO && styles.expoAndroidContainer]}>
171+
<Container>
134172
<KeyboardAvoidingView
135173
enabled={!shouldDisableKeyboardAvoidingView || tabOpen !== PREVIEW}
136174
behavior={IS_IOS ? 'padding' : null}
@@ -142,47 +180,44 @@ const OnDeviceUI = ({
142180
previewDimensions={previewDimensions}
143181
>
144182
<Animated.View style={previewWrapperStyles}>
145-
<Animated.View style={previewStyles}>
146-
<Preview disabled={tabOpen === PREVIEW}>
147-
<WrapperView style={[flex, wrapperMargin]}>
148-
<StoryView />
149-
</WrapperView>
150-
</Preview>
151-
{tabOpen !== PREVIEW ? (
152-
<TouchableOpacity
153-
style={absolutePosition}
154-
onPress={() => handleToggleTab(PREVIEW)}
155-
/>
156-
) : null}
157-
</Animated.View>
183+
<Preview style={safeAreaMargins} animatedValue={animatedValue.current}>
184+
<StoryView />
185+
</Preview>
186+
{tabOpen !== PREVIEW ? (
187+
<TouchableOpacity
188+
style={StyleSheet.absoluteFillObject}
189+
onPress={() => handleToggleTab(PREVIEW)}
190+
/>
191+
) : null}
158192
</Animated.View>
159193
<Panel
160-
style={getNavigatorPanelPosition(
161-
animatedValue.current,
162-
previewDimensions.width,
163-
wide
164-
)}
194+
style={[
195+
getNavigatorPanelPosition(animatedValue.current, previewDimensions.width, wide),
196+
safeAreaMargins,
197+
{ backgroundColor: theme.storyListBackgroundColor },
198+
]}
165199
>
166200
<StoryListView storyIndex={storyIndex} />
167201
</Panel>
168202

169203
<Panel
170204
style={[
171205
getAddonPanelPosition(animatedValue.current, previewDimensions.width, wide),
172-
wrapperMargin,
206+
safeAreaMargins,
173207
]}
174208
>
175209
<Addons active={tabOpen === ADDONS} />
176210
</Panel>
177211
</AbsolutePositionedKeyboardAwareView>
178212
</KeyboardAvoidingView>
179213
<Navigation
214+
onLayout={measureNavigation}
180215
tabOpen={tabOpen}
181216
onChangeTab={handleToggleTab}
182217
isUIVisible={isUIVisible}
183218
setIsUIVisible={setIsUIVisible}
184219
/>
185-
</View>
220+
</Container>
186221
</>
187222
);
188223
};

0 commit comments

Comments
 (0)