Skip to content

Commit ad85b78

Browse files
chrisbobbegnprice
authored andcommitted
offline notice: Animate entrance/exit, and placeholder's height to match
For how we avoid being affected by the bad interactions with react-native-screens on Android, see comments in OfflineNoticeProvider.
1 parent 0bafcae commit ad85b78

File tree

2 files changed

+113
-7
lines changed

2 files changed

+113
-7
lines changed

src/ZulipMobile.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,28 @@ if (Platform.OS === 'android') {
3232
//
3333
// In the meantime, we should be on the lookout for any issues with
3434
// this feature on Android.
35+
//
36+
// There are some bad interactions with react-native-screens. From
37+
// testing, they seem to be Android only, and they go away with
38+
// react-native-screens disabled:
39+
// - With react-native-screens enabled, at least at 3.13.1 and 3.17.0, be
40+
// careful when you want a single action to trigger animated layout
41+
// changes on multiple screens at the same time. If a screen isn't
42+
// focused when the layout change happens, the layout change and its
43+
// animation might not happen until you focus the screen. I seemed to
44+
// be able to fix this by calling react-native-screens's
45+
// enableFreeze(true) at the top of this file, but I don't really
46+
// understand why; here's the doc:
47+
// https://github.com/software-mansion/react-freeze
48+
// - With react-native-screens enabled, at least at 3.13.1 and 3.17.0, if
49+
// I'm on a screen where a layout change is happening, and I navigate
50+
// away during the animation, when I come back to the first screen it
51+
// seems stuck at a snapshot of its layout, mid-animation, when I
52+
// navigated away. I haven't found a fix except, at the moment the
53+
// screen regains focus, to unmount and remount *all* affected views on
54+
// the screen, with a changing `key` pseudoprop. This is awkward and
55+
// unfortunately doesn't prevent the frozen layout from showing for a
56+
// split second before the layout resets.
3557
UIManager.setLayoutAnimationEnabledExperimental(true);
3658
}
3759

src/boot/OfflineNoticeProvider.js

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// @flow strict-local
2-
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
2+
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
33
import type { Node } from 'react';
4-
import { View } from 'react-native';
4+
import { View, Animated, LayoutAnimation, Platform, Easing } from 'react-native';
55
import NetInfo from '@react-native-community/netinfo';
66
import { SafeAreaView } from 'react-native-safe-area-context';
77
import type { ViewProps } from 'react-native/Libraries/Components/View/ViewPropTypes';
@@ -10,7 +10,7 @@ import type { DimensionValue } from 'react-native/Libraries/StyleSheet/StyleShee
1010
import * as logging from '../utils/logging';
1111
import { useGlobalSelector } from '../react-redux';
1212
import { getGlobalSession, getGlobalSettings } from '../directSelectors';
13-
import { useHasStayedTrueForMs } from '../reactUtils';
13+
import { useHasStayedTrueForMs, usePrevious } from '../reactUtils';
1414
import type { JSONableDict } from '../utils/jsonable';
1515
import { createStyleSheet } from '../styles';
1616
import ZulipTextIntl from '../common/ZulipTextIntl';
@@ -89,7 +89,38 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {
8989
const theme = useGlobalSelector(state => getGlobalSettings(state).theme);
9090
const isOnline = useGlobalSelector(state => getGlobalSession(state).isOnline);
9191
const shouldShowUncertaintyNotice = useShouldShowUncertaintyNotice();
92-
const isNoticeVisible = isOnline === false || shouldShowUncertaintyNotice;
92+
93+
// Use local UI state for isNoticeVisible instead of computing directly as
94+
// a `const`, so we can apply LayoutAnimation.configureNext to just the
95+
// visibility state change, instead of, e.g., all layout changes
96+
// potentially caused by an APP_ONLINE action.
97+
const [isNoticeVisible, setIsNoticeVisible] = useState(
98+
isOnline === false || shouldShowUncertaintyNotice,
99+
);
100+
101+
useEffect(() => {
102+
setIsNoticeVisible(oldValue => {
103+
const newValue = isOnline === false || shouldShowUncertaintyNotice;
104+
if (oldValue !== newValue) {
105+
// Animate the entrance and exit of the offline notice. For how we
106+
// animate OfflineNoticePlaceholder, see there.
107+
//
108+
// For the notice, we shouldn't be affected by the known bad
109+
// interactions with react-native-screens, because the notice is
110+
// rootward of all the React Navigation screens in the app. For what
111+
// those bad interactions are, see the comment in ZulipMobile.js on
112+
// `UIManager.setLayoutAnimationEnabledExperimental(true)`.
113+
LayoutAnimation.configureNext({
114+
...LayoutAnimation.Presets.easeInEaseOut,
115+
116+
// Enter slowly to give bad, possibly unexpected news. Leave quickly
117+
// to give good, hoped-for news.
118+
duration: newValue ? 1000 : 300,
119+
});
120+
}
121+
return newValue;
122+
});
123+
}, [isOnline, shouldShowUncertaintyNotice]);
93124

94125
const styles = useMemo(
95126
() =>
@@ -99,6 +130,14 @@ export function OfflineNoticeProvider(props: ProviderProps): Node {
99130
position: 'absolute',
100131

101132
// Whether the notice is visible or tucked away above the window.
133+
//
134+
// (Just as we discovered in 3fa7a7f10 with the lightbox, it seems
135+
// the Animated API wouldn't let us do a translate-transform
136+
// animation with a percentage; that's issue
137+
// https://github.com/facebook/react-native/issues/13107 .
138+
// So we use LayoutAnimation, which is probably better anyway
139+
// because it lets us animate layout changes at the native layer,
140+
// and so won't drop frames when the JavaScript thread is busy.)
102141
...(isNoticeVisible ? { top: 0 } : { bottom: '100%' }),
103142

104143
zIndex: 1,
@@ -237,17 +276,60 @@ export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
237276
const { style: callerStyle } = props;
238277

239278
const { isNoticeVisible, noticeContentAreaHeight } = useContext(OfflineNoticeContext);
279+
const prevIsNoticeVisible = usePrevious(isNoticeVisible, isNoticeVisible);
280+
281+
const plainHeight = isNoticeVisible ? noticeContentAreaHeight : 0;
282+
const animHeight = useRef(new Animated.Value(plainHeight)).current;
283+
284+
// Part of an Android workaround; see where we set the View's height.
285+
useEffect(() => {
286+
if (Platform.OS !== 'android' || prevIsNoticeVisible === isNoticeVisible) {
287+
return;
288+
}
289+
290+
// Should approximate OfflineNoticeProvider's animation curve.
291+
const animation = Animated.timing(animHeight, {
292+
toValue: plainHeight,
293+
duration: isNoticeVisible ? 1000 : 300,
294+
easing: Easing.inOut(t => Easing.ease(t)),
295+
296+
// With `true`, I get an error:
297+
// - On Android: "Animated node with tag […] does not exist"
298+
// - On iOS: "Style property 'height' is not supported by native
299+
// animated module".
300+
useNativeDriver: false,
301+
});
302+
303+
animation.start();
304+
}, [isNoticeVisible, prevIsNoticeVisible, plainHeight, animHeight]);
240305

241306
const style = useMemo(
242307
() => [
243308
{
244-
height: isNoticeVisible ? noticeContentAreaHeight : 0,
309+
height:
310+
/* prettier-ignore */
311+
Platform.OS === 'android'
312+
// Avoid some bad interactions on Android with
313+
// react-native-screens; see the comment in ZulipMobile.js on
314+
// `UIManager.setLayoutAnimationEnabledExperimental(true)`. To
315+
// avoid those, don't pass `plainHeight`, which would cause the
316+
// resulting layout change to be animated with
317+
// OfflineNoticeProvider's LayoutAnimation.configureNext call.
318+
// Instead, use RN's Animated API.
319+
? animHeight
320+
// Do pass `plainHeight`, to piggy-back on
321+
// OfflineNoticeProvider's LayoutAnimation call. Nothing seems
322+
// to break this simple use of LayoutAnimation on iOS, and it's
323+
// better than Animated because Animated can drop animation
324+
// frames when the CPU is busy (at least without
325+
// `useNativeDriver: true`, and that doesn't support `height`).
326+
: plainHeight,
245327
width: '100%',
246328
backgroundColor: 'transparent',
247329
},
248330
callerStyle,
249331
],
250-
[isNoticeVisible, noticeContentAreaHeight, callerStyle],
332+
[plainHeight, animHeight, callerStyle],
251333
);
252334

253335
return (
@@ -264,7 +346,9 @@ export function OfflineNoticePlaceholder(props: PlaceholderProps): Node {
264346
/>
265347
)
266348
}
267-
<View style={style} />
349+
{/* The `Animated.View` is for the Android workaround; iOS could use a
350+
regular View. See comment where we set the height attribute. */}
351+
<Animated.View style={style} />
268352
</>
269353
);
270354
}

0 commit comments

Comments
 (0)